@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.134.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.134.0",
20
- "@cosmicdrift/kumiko-headless": "0.134.0",
21
- "@cosmicdrift/kumiko-renderer": "0.134.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 { readonly label: string; readonly value: ReactNode }[];
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 className="text-sm text-muted-foreground">{row.label}</dt>
20
- <dd className="text-sm font-medium">{row.value}</dd>
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
+ }
@@ -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
+ }