@cosmicdrift/kumiko-renderer-web 0.134.0 → 0.135.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/package.json +4 -4
- package/src/index.ts +8 -0
- package/src/widgets/__tests__/form-kit.test.tsx +103 -0
- package/src/widgets/detail-list.tsx +19 -4
- package/src/widgets/form-fields.tsx +66 -0
- package/src/widgets/index.ts +3 -0
- package/src/widgets/result-panel.tsx +101 -0
- package/src/widgets/use-draft.ts +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.135.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.135.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.135.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.135.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
package/src/index.ts
CHANGED
|
@@ -156,9 +156,11 @@ export {
|
|
|
156
156
|
export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
|
|
157
157
|
export type {
|
|
158
158
|
FeedRow,
|
|
159
|
+
NumberFieldProps,
|
|
159
160
|
ProgressListRow,
|
|
160
161
|
QueryTableColumn,
|
|
161
162
|
QueryTableProps,
|
|
163
|
+
ResultColumn,
|
|
162
164
|
StatDelta,
|
|
163
165
|
StatTone,
|
|
164
166
|
StatusBarEntry,
|
|
@@ -174,9 +176,14 @@ export {
|
|
|
174
176
|
LoadingState,
|
|
175
177
|
MiniStat,
|
|
176
178
|
ModeSwitch,
|
|
179
|
+
MoneyField,
|
|
180
|
+
NumberField,
|
|
181
|
+
PercentField,
|
|
177
182
|
ProgressBar,
|
|
178
183
|
ProgressList,
|
|
179
184
|
QueryTable,
|
|
185
|
+
ResultPanel,
|
|
186
|
+
ResultTable,
|
|
180
187
|
SectionCard,
|
|
181
188
|
Sparkline,
|
|
182
189
|
STATUS_TONE_TEXT,
|
|
@@ -185,4 +192,5 @@ export {
|
|
|
185
192
|
StatusBarChart,
|
|
186
193
|
smoothPath,
|
|
187
194
|
TimeseriesChart,
|
|
195
|
+
useDraft,
|
|
188
196
|
} from "./widgets";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { act, fireEvent, render, renderHook, screen } from "../../__tests__/test-utils";
|
|
3
|
+
import { DetailList } from "../detail-list";
|
|
4
|
+
import { MoneyField, NumberField, PercentField } from "../form-fields";
|
|
5
|
+
import { ResultPanel, ResultTable } from "../result-panel";
|
|
6
|
+
import { useDraft } from "../use-draft";
|
|
7
|
+
|
|
8
|
+
describe("useDraft", () => {
|
|
9
|
+
test("field() liefert verdrahtete Props, onChange patcht den Draft", () => {
|
|
10
|
+
const { result } = renderHook(() => useDraft<{ sum: number | undefined }>({ sum: 100 }));
|
|
11
|
+
expect(result.current.field("sum")).toMatchObject({ id: "sum", name: "sum", value: 100 });
|
|
12
|
+
act(() => result.current.field("sum").onChange(250));
|
|
13
|
+
expect(result.current.draft.sum).toBe(250);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("reset stellt die Defaults wieder her", () => {
|
|
17
|
+
const { result } = renderHook(() => useDraft<{ a: number | undefined }>({ a: 1 }));
|
|
18
|
+
act(() => result.current.patch({ a: 9 }));
|
|
19
|
+
expect(result.current.draft.a).toBe(9);
|
|
20
|
+
act(() => result.current.reset());
|
|
21
|
+
expect(result.current.draft.a).toBe(1);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("NumberField", () => {
|
|
26
|
+
test("rendert Label und meldet Zahl bei Eingabe", () => {
|
|
27
|
+
const onChange = mock();
|
|
28
|
+
render(<NumberField label="Summe" id="sum" name="sum" value={300} onChange={onChange} />);
|
|
29
|
+
expect(screen.getByText("Summe")).toBeTruthy();
|
|
30
|
+
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "42" } });
|
|
31
|
+
expect(onChange).toHaveBeenCalledWith(42);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("leeres Feld meldet undefined", () => {
|
|
35
|
+
const onChange = mock();
|
|
36
|
+
render(<NumberField label="X" id="x" name="x" value={5} onChange={onChange} />);
|
|
37
|
+
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "" } });
|
|
38
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("MoneyField/PercentField zeigen ihre Einheit", () => {
|
|
42
|
+
const noop = (): void => {};
|
|
43
|
+
const { rerender } = render(
|
|
44
|
+
<MoneyField label="Betrag" id="b" name="b" value={1} onChange={noop} />,
|
|
45
|
+
);
|
|
46
|
+
expect(screen.getByText("€")).toBeTruthy();
|
|
47
|
+
rerender(<PercentField label="Zins" id="z" name="z" value={1} onChange={noop} />);
|
|
48
|
+
expect(screen.getByText("%")).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("DetailList emphasize", () => {
|
|
53
|
+
test("emphasize hebt Label und Wert hervor", () => {
|
|
54
|
+
render(
|
|
55
|
+
<DetailList
|
|
56
|
+
testId="dl"
|
|
57
|
+
rows={[
|
|
58
|
+
{ label: "Summe", value: "100" },
|
|
59
|
+
{ label: "Effektiv", value: "3,1 %", emphasize: true },
|
|
60
|
+
]}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
expect(screen.getByText("Effektiv").className).toContain("font-semibold");
|
|
64
|
+
expect(screen.getByText("Summe").className).not.toContain("font-semibold");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("ResultPanel", () => {
|
|
69
|
+
test("empty zeigt den Platzhalter, keine Liste", () => {
|
|
70
|
+
render(<ResultPanel title="Ergebnis" empty emptyText="Werte eingeben" />);
|
|
71
|
+
expect(screen.getByText("Werte eingeben")).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("gefüllt zeigt Kennzahlen und children", () => {
|
|
75
|
+
render(
|
|
76
|
+
<ResultPanel title="Ergebnis" rows={[{ label: "Rate", value: "890 €" }]}>
|
|
77
|
+
<span>extra</span>
|
|
78
|
+
</ResultPanel>,
|
|
79
|
+
);
|
|
80
|
+
expect(screen.getByText("Rate")).toBeTruthy();
|
|
81
|
+
expect(screen.getByText("extra")).toBeTruthy();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("ResultTable", () => {
|
|
86
|
+
test("rendert Header, Zeilen und rechtsbündige Zahlenspalte", () => {
|
|
87
|
+
render(
|
|
88
|
+
<ResultTable
|
|
89
|
+
testId="rt"
|
|
90
|
+
columns={[
|
|
91
|
+
{ header: "Tranche", cell: (r: { name: string; sum: string }) => r.name },
|
|
92
|
+
{ header: "Summe", align: "right", cell: (r) => r.sum },
|
|
93
|
+
]}
|
|
94
|
+
rows={[{ name: "Bank", sum: "300.000 €" }]}
|
|
95
|
+
rowKey={(r) => r.name}
|
|
96
|
+
/>,
|
|
97
|
+
);
|
|
98
|
+
expect(screen.getByText("Tranche")).toBeTruthy();
|
|
99
|
+
expect(screen.getByText("Bank")).toBeTruthy();
|
|
100
|
+
expect(screen.getByText("Summe").className).toContain("text-right");
|
|
101
|
+
expect(screen.getByText("300.000 €").className).toContain("tabular-nums");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
2
3
|
|
|
3
4
|
/** Read-only Schlüssel-Wert-Liste für Detail-Masken (Label links gedimmt,
|
|
4
|
-
* Wert rechts). Wert ist ReactNode → Badges/Chips möglich.
|
|
5
|
+
* Wert rechts). Wert ist ReactNode → Badges/Chips möglich. `emphasize` hebt
|
|
6
|
+
* eine Zeile hervor (z.B. das Endergebnis in einem Rechner). */
|
|
5
7
|
export function DetailList({
|
|
6
8
|
rows,
|
|
7
9
|
testId,
|
|
8
10
|
}: {
|
|
9
|
-
readonly rows: readonly {
|
|
11
|
+
readonly rows: readonly {
|
|
12
|
+
readonly label: string;
|
|
13
|
+
readonly value: ReactNode;
|
|
14
|
+
readonly emphasize?: boolean;
|
|
15
|
+
}[];
|
|
10
16
|
readonly testId?: string;
|
|
11
17
|
}): ReactNode {
|
|
12
18
|
return (
|
|
@@ -16,8 +22,17 @@ export function DetailList({
|
|
|
16
22
|
key={row.label}
|
|
17
23
|
className="grid grid-cols-1 gap-0.5 py-2.5 sm:grid-cols-[200px_1fr] sm:gap-4"
|
|
18
24
|
>
|
|
19
|
-
<dt
|
|
20
|
-
|
|
25
|
+
<dt
|
|
26
|
+
className={cn(
|
|
27
|
+
"text-sm text-muted-foreground",
|
|
28
|
+
row.emphasize === true && "font-semibold text-foreground",
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
{row.label}
|
|
32
|
+
</dt>
|
|
33
|
+
<dd className={cn("text-sm font-medium", row.emphasize === true && "font-semibold")}>
|
|
34
|
+
{row.value}
|
|
35
|
+
</dd>
|
|
21
36
|
</div>
|
|
22
37
|
))}
|
|
23
38
|
</dl>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export interface NumberFieldProps {
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly value: number | undefined;
|
|
9
|
+
readonly onChange: (v: number | undefined) => void;
|
|
10
|
+
readonly required?: boolean;
|
|
11
|
+
readonly disabled?: boolean;
|
|
12
|
+
/** Einheit rechts neben dem Label (z.B. "€", "%"). */
|
|
13
|
+
readonly unit?: string;
|
|
14
|
+
readonly testId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Zahlenfeld = Field + Input(kind:"number") in einem — nimmt der Screen die
|
|
18
|
+
* wiederholte id/name/value/onChange-Verdrahtung ab. `value` darf `undefined`
|
|
19
|
+
* sein (leeres Feld), intern auf den `""`-Empty-State des Inputs gemappt. */
|
|
20
|
+
export function NumberField({
|
|
21
|
+
label,
|
|
22
|
+
id,
|
|
23
|
+
name,
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
required,
|
|
27
|
+
disabled,
|
|
28
|
+
unit,
|
|
29
|
+
testId,
|
|
30
|
+
}: NumberFieldProps): ReactNode {
|
|
31
|
+
const { Field, Input } = usePrimitives();
|
|
32
|
+
return (
|
|
33
|
+
<Field
|
|
34
|
+
id={id}
|
|
35
|
+
label={label}
|
|
36
|
+
required={required}
|
|
37
|
+
labelAppendix={
|
|
38
|
+
unit !== undefined ? (
|
|
39
|
+
<span className="text-xs text-muted-foreground">{unit}</span>
|
|
40
|
+
) : undefined
|
|
41
|
+
}
|
|
42
|
+
testId={testId}
|
|
43
|
+
>
|
|
44
|
+
<Input
|
|
45
|
+
kind="number"
|
|
46
|
+
id={id}
|
|
47
|
+
name={name}
|
|
48
|
+
value={value ?? ""}
|
|
49
|
+
onChange={onChange}
|
|
50
|
+
required={required}
|
|
51
|
+
disabled={disabled}
|
|
52
|
+
/>
|
|
53
|
+
</Field>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Euro-Betrag in ganzen Einheiten (kein Cent-Integer wie Input kind:"money").
|
|
58
|
+
* Preset über NumberField mit "€"-Einheit. */
|
|
59
|
+
export function MoneyField(props: Omit<NumberFieldProps, "unit">): ReactNode {
|
|
60
|
+
return <NumberField {...props} unit="€" />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Prozentwert (Zins, Tilgung, Rendite). Preset über NumberField mit "%". */
|
|
64
|
+
export function PercentField(props: Omit<NumberFieldProps, "unit">): ReactNode {
|
|
65
|
+
return <NumberField {...props} unit="%" />;
|
|
66
|
+
}
|
package/src/widgets/index.ts
CHANGED
|
@@ -13,11 +13,14 @@ export {
|
|
|
13
13
|
export { CollapsibleSection } from "./collapsible-section";
|
|
14
14
|
export { DetailList } from "./detail-list";
|
|
15
15
|
export { FeedList, type FeedRow } from "./feed-list";
|
|
16
|
+
export { MoneyField, NumberField, type NumberFieldProps, PercentField } from "./form-fields";
|
|
16
17
|
export { ModeSwitch } from "./mode-switch";
|
|
17
18
|
export { ProgressBar } from "./progress-bar";
|
|
18
19
|
export { ProgressList, type ProgressListRow } from "./progress-list";
|
|
19
20
|
export { QueryTable, type QueryTableColumn, type QueryTableProps } from "./query-table";
|
|
21
|
+
export { type ResultColumn, ResultPanel, ResultTable } from "./result-panel";
|
|
20
22
|
export { SectionCard } from "./section-card";
|
|
21
23
|
export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
|
|
22
24
|
export { EmptyState, ErrorState, LoadingState } from "./states";
|
|
23
25
|
export { STATUS_TONE_TEXT, StatusBadge, type StatusTone } from "./status-badge";
|
|
26
|
+
export { useDraft } from "./use-draft";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { DetailList } from "./detail-list";
|
|
5
|
+
import { SectionCard } from "./section-card";
|
|
6
|
+
|
|
7
|
+
/** Ergebnis-Sektion eines Rechners: SectionCard mit Empty-Zustand (Banner)
|
|
8
|
+
* oder Kennzahl-Liste (DetailList) + optionalen Extras (Tabelle, Hinweise).
|
|
9
|
+
* Ersetzt die handgebauten `<dl>`/Banner-Blöcke in Custom-Screens. */
|
|
10
|
+
export function ResultPanel({
|
|
11
|
+
title,
|
|
12
|
+
subtitle,
|
|
13
|
+
empty,
|
|
14
|
+
emptyText,
|
|
15
|
+
rows,
|
|
16
|
+
children,
|
|
17
|
+
testId,
|
|
18
|
+
}: {
|
|
19
|
+
readonly title: string;
|
|
20
|
+
readonly subtitle?: string;
|
|
21
|
+
readonly empty?: boolean;
|
|
22
|
+
readonly emptyText?: ReactNode;
|
|
23
|
+
readonly rows?: readonly {
|
|
24
|
+
readonly label: string;
|
|
25
|
+
readonly value: ReactNode;
|
|
26
|
+
readonly emphasize?: boolean;
|
|
27
|
+
}[];
|
|
28
|
+
readonly children?: ReactNode;
|
|
29
|
+
readonly testId?: string;
|
|
30
|
+
}): ReactNode {
|
|
31
|
+
const { Banner } = usePrimitives();
|
|
32
|
+
return (
|
|
33
|
+
<SectionCard title={title} subtitle={subtitle} testId={testId}>
|
|
34
|
+
{empty === true ? (
|
|
35
|
+
<Banner variant="info" padded>
|
|
36
|
+
{emptyText}
|
|
37
|
+
</Banner>
|
|
38
|
+
) : (
|
|
39
|
+
<div className="flex flex-col gap-4">
|
|
40
|
+
{rows !== undefined && rows.length > 0 && <DetailList rows={rows} />}
|
|
41
|
+
{children}
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
</SectionCard>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ResultColumn<Row> {
|
|
49
|
+
readonly header: string;
|
|
50
|
+
readonly align?: "left" | "right";
|
|
51
|
+
readonly cell: (row: Row) => ReactNode;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Statische, prop-getriebene Ergebnistabelle für berechnete Zeilen (Tranchen,
|
|
55
|
+
* Szenarien). Nimmt dem Screen die `<table>`+tabular-nums-Ketten ab.
|
|
56
|
+
* ponytail: bewusst die minimale Read-only-Tabelle — für Sort/Pager/Facets
|
|
57
|
+
* stattdessen usePrimitives().DataTable bzw. QueryTable nutzen. */
|
|
58
|
+
export function ResultTable<Row>({
|
|
59
|
+
columns,
|
|
60
|
+
rows,
|
|
61
|
+
rowKey,
|
|
62
|
+
testId,
|
|
63
|
+
}: {
|
|
64
|
+
readonly columns: readonly ResultColumn<Row>[];
|
|
65
|
+
readonly rows: readonly Row[];
|
|
66
|
+
readonly rowKey: (row: Row, index: number) => string;
|
|
67
|
+
readonly testId?: string;
|
|
68
|
+
}): ReactNode {
|
|
69
|
+
return (
|
|
70
|
+
<div className="overflow-x-auto">
|
|
71
|
+
<table data-testid={testId} className="w-full text-sm">
|
|
72
|
+
<thead>
|
|
73
|
+
<tr className="border-b text-left text-muted-foreground">
|
|
74
|
+
{columns.map((col) => (
|
|
75
|
+
<th
|
|
76
|
+
key={col.header}
|
|
77
|
+
className={cn("py-1.5 font-medium", col.align === "right" && "text-right")}
|
|
78
|
+
>
|
|
79
|
+
{col.header}
|
|
80
|
+
</th>
|
|
81
|
+
))}
|
|
82
|
+
</tr>
|
|
83
|
+
</thead>
|
|
84
|
+
<tbody>
|
|
85
|
+
{rows.map((row, i) => (
|
|
86
|
+
<tr key={rowKey(row, i)} className="border-b last:border-0">
|
|
87
|
+
{columns.map((col) => (
|
|
88
|
+
<td
|
|
89
|
+
key={col.header}
|
|
90
|
+
className={cn("py-1.5", col.align === "right" && "text-right tabular-nums")}
|
|
91
|
+
>
|
|
92
|
+
{col.cell(row)}
|
|
93
|
+
</td>
|
|
94
|
+
))}
|
|
95
|
+
</tr>
|
|
96
|
+
))}
|
|
97
|
+
</tbody>
|
|
98
|
+
</table>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/** Formular-State für Rechner-Screens: ein Draft-Objekt + `patch` für
|
|
4
|
+
* Teil-Updates + `field(name)` das die `{ id, name, value, onChange }`-Props
|
|
5
|
+
* fertig für die Feld-Widgets (NumberField/MoneyField/PercentField) liefert.
|
|
6
|
+
* Ersetzt das pro Screen wiederholte Draft-Interface + patch-Helper. */
|
|
7
|
+
export function useDraft<T extends object>(
|
|
8
|
+
defaults: T,
|
|
9
|
+
): {
|
|
10
|
+
readonly draft: T;
|
|
11
|
+
readonly patch: (changes: Partial<T>) => void;
|
|
12
|
+
readonly reset: () => void;
|
|
13
|
+
readonly field: <K extends keyof T>(
|
|
14
|
+
name: K,
|
|
15
|
+
) => {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly value: T[K];
|
|
19
|
+
readonly onChange: (v: T[K]) => void;
|
|
20
|
+
};
|
|
21
|
+
} {
|
|
22
|
+
const [draft, setDraft] = useState<T>(defaults);
|
|
23
|
+
const patch = useCallback((changes: Partial<T>) => setDraft((d) => ({ ...d, ...changes })), []);
|
|
24
|
+
const reset = useCallback(() => setDraft(defaults), [defaults]);
|
|
25
|
+
const field = useCallback(
|
|
26
|
+
<K extends keyof T>(name: K) => ({
|
|
27
|
+
id: String(name),
|
|
28
|
+
name: String(name),
|
|
29
|
+
value: draft[name],
|
|
30
|
+
onChange: (v: T[K]) => setDraft((d) => ({ ...d, [name]: v })),
|
|
31
|
+
}),
|
|
32
|
+
[draft],
|
|
33
|
+
);
|
|
34
|
+
return { draft, patch, reset, field };
|
|
35
|
+
}
|