@cosmicdrift/kumiko-renderer-web 0.130.2 → 0.132.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/__tests__/dashboard-screen.test.tsx +86 -0
- package/src/__tests__/projection-list-actions.test.tsx +89 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/app/create-app.tsx +18 -14
- package/src/app/dashboard-body.tsx +146 -0
- package/src/index.ts +28 -0
- package/src/primitives/index.tsx +48 -3
- package/src/primitives/toast.tsx +14 -9
- package/src/styles.css +14 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/widgets.test.tsx +229 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +25 -0
- package/src/widgets/index.ts +21 -0
- package/src/widgets/mode-switch.tsx +40 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import {
|
|
6
|
+
createMockDispatcher,
|
|
7
|
+
fireEvent,
|
|
8
|
+
render,
|
|
9
|
+
screen,
|
|
10
|
+
waitFor,
|
|
11
|
+
} from "../../__tests__/test-utils";
|
|
12
|
+
import { QueryTable } from "../query-table";
|
|
13
|
+
|
|
14
|
+
function renderWithDispatcher(ui: ReactNode, dispatcher: Dispatcher) {
|
|
15
|
+
return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const COLUMNS = [
|
|
19
|
+
{ field: "name", label: "Name" },
|
|
20
|
+
{ field: "plan", label: "Plan" },
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
describe("QueryTable", () => {
|
|
24
|
+
test("rendert Query-Result als DataTable-Rows", async () => {
|
|
25
|
+
const dispatcher = createMockDispatcher({
|
|
26
|
+
query: (async () => ({
|
|
27
|
+
isSuccess: true,
|
|
28
|
+
data: [
|
|
29
|
+
{ id: "t1", name: "Acme", plan: "pro" },
|
|
30
|
+
{ id: "t2", name: "Beta GmbH", plan: "free" },
|
|
31
|
+
],
|
|
32
|
+
})) as unknown as Dispatcher["query"],
|
|
33
|
+
});
|
|
34
|
+
renderWithDispatcher(
|
|
35
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} testId="tbl" />,
|
|
36
|
+
dispatcher,
|
|
37
|
+
);
|
|
38
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
39
|
+
expect(screen.getByText("Beta GmbH")).toBeTruthy();
|
|
40
|
+
expect(screen.getByText("Name")).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("Row-Click liefert die geklickte Row", async () => {
|
|
44
|
+
const onRowClick = mock((_row: { id: string }) => {});
|
|
45
|
+
const dispatcher = createMockDispatcher({
|
|
46
|
+
query: (async () => ({
|
|
47
|
+
isSuccess: true,
|
|
48
|
+
data: [{ id: "t1", name: "Acme", plan: "pro" }],
|
|
49
|
+
})) as unknown as Dispatcher["query"],
|
|
50
|
+
});
|
|
51
|
+
renderWithDispatcher(
|
|
52
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} onRowClick={onRowClick} />,
|
|
53
|
+
dispatcher,
|
|
54
|
+
);
|
|
55
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
56
|
+
fireEvent.click(screen.getByText("Acme"));
|
|
57
|
+
expect(onRowClick).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(onRowClick.mock.calls[0]?.[0]?.id).toBe("t1");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("Fehler → ErrorState, Retry refetcht", async () => {
|
|
62
|
+
let calls = 0;
|
|
63
|
+
const dispatcher = createMockDispatcher({
|
|
64
|
+
query: (async () => {
|
|
65
|
+
calls += 1;
|
|
66
|
+
if (calls === 1) {
|
|
67
|
+
return {
|
|
68
|
+
isSuccess: false,
|
|
69
|
+
error: { code: "internal", message: "kaputt", i18nKey: "errors.internal" },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { isSuccess: true, data: [{ id: "t1", name: "Acme", plan: "pro" }] };
|
|
73
|
+
}) as unknown as Dispatcher["query"],
|
|
74
|
+
});
|
|
75
|
+
renderWithDispatcher(
|
|
76
|
+
<QueryTable query="tenant:query:tenant:list" columns={COLUMNS} testId="tbl" />,
|
|
77
|
+
dispatcher,
|
|
78
|
+
);
|
|
79
|
+
await waitFor(() => expect(screen.getByRole("button")).toBeTruthy());
|
|
80
|
+
fireEvent.click(screen.getByRole("button"));
|
|
81
|
+
await waitFor(() => expect(screen.getByText("Acme")).toBeTruthy());
|
|
82
|
+
expect(calls).toBe(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("leeres Result rendert Empty-State", async () => {
|
|
86
|
+
const dispatcher = createMockDispatcher({
|
|
87
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
88
|
+
});
|
|
89
|
+
const { container } = renderWithDispatcher(
|
|
90
|
+
<QueryTable
|
|
91
|
+
query="tenant:query:tenant:list"
|
|
92
|
+
columns={COLUMNS}
|
|
93
|
+
emptyState={<span>Keine Tenants</span>}
|
|
94
|
+
/>,
|
|
95
|
+
dispatcher,
|
|
96
|
+
);
|
|
97
|
+
await waitFor(() => expect(screen.getByText("Keine Tenants")).toBeTruthy());
|
|
98
|
+
expect(container.querySelectorAll("tbody tr").length).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("rows-Selector zieht Rows aus verschachteltem Result", async () => {
|
|
102
|
+
const dispatcher = createMockDispatcher({
|
|
103
|
+
query: (async () => ({
|
|
104
|
+
isSuccess: true,
|
|
105
|
+
data: { items: [{ id: "x", name: "Nested", plan: "pro" }], total: 1 },
|
|
106
|
+
})) as unknown as Dispatcher["query"],
|
|
107
|
+
});
|
|
108
|
+
renderWithDispatcher(
|
|
109
|
+
<QueryTable<{ items: readonly Record<string, unknown>[]; total: number }>
|
|
110
|
+
query="tenant:query:tenant:list"
|
|
111
|
+
columns={COLUMNS}
|
|
112
|
+
rows={(data) => data.items}
|
|
113
|
+
/>,
|
|
114
|
+
dispatcher,
|
|
115
|
+
);
|
|
116
|
+
await waitFor(() => expect(screen.getByText("Nested")).toBeTruthy());
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { fireEvent, render, screen } from "../../__tests__/test-utils";
|
|
3
|
+
import { StatusBarChart, smoothPath, TimeseriesChart } from "../charts";
|
|
4
|
+
import { CollapsibleSection } from "../collapsible-section";
|
|
5
|
+
import { DetailList } from "../detail-list";
|
|
6
|
+
import { ModeSwitch } from "../mode-switch";
|
|
7
|
+
import { ProgressBar } from "../progress-bar";
|
|
8
|
+
import { SectionCard } from "../section-card";
|
|
9
|
+
import { MiniStat, StatCard } from "../stat";
|
|
10
|
+
import { EmptyState } from "../states";
|
|
11
|
+
import { StatusBadge } from "../status-badge";
|
|
12
|
+
|
|
13
|
+
describe("StatusBadge", () => {
|
|
14
|
+
test("rendert Label mit Tone-Klassen", () => {
|
|
15
|
+
render(
|
|
16
|
+
<StatusBadge tone="ok" testId="badge">
|
|
17
|
+
Operational
|
|
18
|
+
</StatusBadge>,
|
|
19
|
+
);
|
|
20
|
+
const badge = screen.getByTestId("badge");
|
|
21
|
+
expect(badge.textContent).toBe("Operational");
|
|
22
|
+
expect(badge.className).toContain("text-status-ok");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("muted nutzt die neutralen Theme-Tokens", () => {
|
|
26
|
+
render(
|
|
27
|
+
<StatusBadge tone="muted" testId="badge">
|
|
28
|
+
Resolved
|
|
29
|
+
</StatusBadge>,
|
|
30
|
+
);
|
|
31
|
+
expect(screen.getByTestId("badge").className).toContain("text-muted-foreground");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("ProgressBar", () => {
|
|
36
|
+
test("clampt value auf 0..1 und setzt aria", () => {
|
|
37
|
+
render(<ProgressBar value={1.7} testId="bar" />);
|
|
38
|
+
expect(screen.getByTestId("bar").getAttribute("aria-valuenow")).toBe("100");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("negative Werte werden 0", () => {
|
|
42
|
+
render(<ProgressBar value={-3} testId="bar" />);
|
|
43
|
+
expect(screen.getByTestId("bar").getAttribute("aria-valuenow")).toBe("0");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("ModeSwitch", () => {
|
|
48
|
+
test("markiert aktive Option und feuert onChange", () => {
|
|
49
|
+
const onChange = mock((_v: string) => {});
|
|
50
|
+
render(
|
|
51
|
+
<ModeSwitch
|
|
52
|
+
value="a"
|
|
53
|
+
options={[
|
|
54
|
+
{ value: "a", label: "Modus A" },
|
|
55
|
+
{ value: "b", label: "Modus B" },
|
|
56
|
+
]}
|
|
57
|
+
onChange={onChange}
|
|
58
|
+
/>,
|
|
59
|
+
);
|
|
60
|
+
const active = screen.getByRole("button", { name: "Modus A" });
|
|
61
|
+
expect(active.getAttribute("aria-pressed")).toBe("true");
|
|
62
|
+
fireEvent.click(screen.getByRole("button", { name: "Modus B" }));
|
|
63
|
+
expect(onChange).toHaveBeenCalledWith("b");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("CollapsibleSection", () => {
|
|
68
|
+
test("async geflipptes defaultOpen öffnet nachträglich", () => {
|
|
69
|
+
const { rerender, container } = render(
|
|
70
|
+
<CollapsibleSection title="Erweitert" defaultOpen={false}>
|
|
71
|
+
<span>Inhalt</span>
|
|
72
|
+
</CollapsibleSection>,
|
|
73
|
+
);
|
|
74
|
+
const details = container.querySelector("details");
|
|
75
|
+
expect(details?.open).toBe(false);
|
|
76
|
+
rerender(
|
|
77
|
+
<CollapsibleSection title="Erweitert" defaultOpen={true}>
|
|
78
|
+
<span>Inhalt</span>
|
|
79
|
+
</CollapsibleSection>,
|
|
80
|
+
);
|
|
81
|
+
expect(container.querySelector("details")?.open).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("DetailList", () => {
|
|
86
|
+
test("rendert Label/Wert-Paare als dl", () => {
|
|
87
|
+
render(<DetailList rows={[{ label: "Name", value: "Acme" }]} testId="dl" />);
|
|
88
|
+
expect(screen.getByText("Name").tagName).toBe("DT");
|
|
89
|
+
expect(screen.getByText("Acme").tagName).toBe("DD");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("SectionCard", () => {
|
|
94
|
+
test("rendert Titel, Action-Slot und Children über das Card-Primitive", () => {
|
|
95
|
+
render(
|
|
96
|
+
<SectionCard title="Verlauf" action={<button type="button">Range</button>}>
|
|
97
|
+
<span>Body</span>
|
|
98
|
+
</SectionCard>,
|
|
99
|
+
);
|
|
100
|
+
expect(screen.getByText("Verlauf")).toBeTruthy();
|
|
101
|
+
expect(screen.getByRole("button", { name: "Range" })).toBeTruthy();
|
|
102
|
+
expect(screen.getByText("Body")).toBeTruthy();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("StatCard", () => {
|
|
107
|
+
test("rendert Label, Wert, Delta und Sub-Zeile", () => {
|
|
108
|
+
render(
|
|
109
|
+
<StatCard
|
|
110
|
+
label="Restschuld"
|
|
111
|
+
value="123.456 €"
|
|
112
|
+
sub="nach 10 Jahren"
|
|
113
|
+
delta={{ value: "2,1 %", direction: "down" }}
|
|
114
|
+
/>,
|
|
115
|
+
);
|
|
116
|
+
expect(screen.getByText("Restschuld")).toBeTruthy();
|
|
117
|
+
expect(screen.getByText("123.456 €")).toBeTruthy();
|
|
118
|
+
expect(screen.getByText(/2,1 %/)).toBeTruthy();
|
|
119
|
+
expect(screen.getByText("nach 10 Jahren")).toBeTruthy();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("accentColor färbt den Icon-Chip inline", () => {
|
|
123
|
+
const { container } = render(
|
|
124
|
+
<StatCard
|
|
125
|
+
icon={<svg aria-hidden="true" />}
|
|
126
|
+
label="Zins"
|
|
127
|
+
value="3,1 %"
|
|
128
|
+
accentColor="#123456"
|
|
129
|
+
/>,
|
|
130
|
+
);
|
|
131
|
+
const chip = container.querySelector("span[style]");
|
|
132
|
+
// happy-dom parst color-mix()-backgroundColor nicht — color reicht als Beleg.
|
|
133
|
+
expect(chip?.getAttribute("style") ?? "").toContain("#123456");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("MiniStat", () => {
|
|
138
|
+
test("emphasize hebt die Kachel mit Ring hervor", () => {
|
|
139
|
+
render(<MiniStat label="Rate" value="890 €" emphasize testId="mini" />);
|
|
140
|
+
expect(screen.getByTestId("mini").className).toContain("ring-1");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("EmptyState", () => {
|
|
145
|
+
test("rendert Titel, Beschreibung und CTA", () => {
|
|
146
|
+
render(
|
|
147
|
+
<EmptyState
|
|
148
|
+
title="Noch keine Monitore"
|
|
149
|
+
description="Lege den ersten an."
|
|
150
|
+
action={<button type="button">Neu</button>}
|
|
151
|
+
/>,
|
|
152
|
+
);
|
|
153
|
+
expect(screen.getByText("Noch keine Monitore")).toBeTruthy();
|
|
154
|
+
expect(screen.getByRole("button", { name: "Neu" })).toBeTruthy();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("smoothPath", () => {
|
|
159
|
+
test("leer → leerer Pfad, ein Punkt → Move + Line auf sich selbst", () => {
|
|
160
|
+
expect(smoothPath([])).toBe("");
|
|
161
|
+
expect(smoothPath([{ x: 1, y: 2 }])).toBe("M 1.0 2.0 L 1.0 2.0");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("glättet über Quadratic-Midpoints und endet am letzten Punkt", () => {
|
|
165
|
+
const d = smoothPath([
|
|
166
|
+
{ x: 0, y: 0 },
|
|
167
|
+
{ x: 10, y: 20 },
|
|
168
|
+
{ x: 20, y: 0 },
|
|
169
|
+
]);
|
|
170
|
+
expect(d.startsWith("M 0.0 0.0")).toBe(true);
|
|
171
|
+
expect(d).toContain("Q 10.0 20.0, 15.0 10.0");
|
|
172
|
+
expect(d.endsWith("L 20.0 0.0")).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("StatusBarChart", () => {
|
|
177
|
+
test("rendert einen Bar pro Entry mit aria-Label", () => {
|
|
178
|
+
const { container } = render(
|
|
179
|
+
<StatusBarChart
|
|
180
|
+
ariaLabel="Uptime 90 Tage"
|
|
181
|
+
entries={[
|
|
182
|
+
{ key: "d1", level: 1, tone: "ok" },
|
|
183
|
+
{ key: "d2", level: 0.5, tone: "bad" },
|
|
184
|
+
]}
|
|
185
|
+
startLabel="90 Tage"
|
|
186
|
+
endLabel="heute"
|
|
187
|
+
/>,
|
|
188
|
+
);
|
|
189
|
+
expect(screen.getByRole("img", { name: "Uptime 90 Tage" })).toBeTruthy();
|
|
190
|
+
// 2 Entries × (Gradient-Bar + Tick) + 1 Last-Highlight-Stripe = 5 rects
|
|
191
|
+
expect(container.querySelectorAll("rect").length).toBe(5);
|
|
192
|
+
expect(screen.getByText("heute")).toBeTruthy();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("TimeseriesChart", () => {
|
|
197
|
+
test("unter 2 Messwerten rendert emptyContent statt Chart", () => {
|
|
198
|
+
const { container } = render(
|
|
199
|
+
<TimeseriesChart
|
|
200
|
+
points={[{ atMs: 1000, value: 42 }]}
|
|
201
|
+
windowStartMs={0}
|
|
202
|
+
windowEndMs={2000}
|
|
203
|
+
ariaLabel="Antwortzeit"
|
|
204
|
+
emptyContent={<span>Noch keine Messdaten</span>}
|
|
205
|
+
/>,
|
|
206
|
+
);
|
|
207
|
+
expect(screen.getByText("Noch keine Messdaten")).toBeTruthy();
|
|
208
|
+
expect(container.querySelector("svg")).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("rendert Linie + Fläche + Achsen-Labels", () => {
|
|
212
|
+
const { container } = render(
|
|
213
|
+
<TimeseriesChart
|
|
214
|
+
points={[
|
|
215
|
+
{ atMs: 0, value: 100 },
|
|
216
|
+
{ atMs: 1000, value: 200 },
|
|
217
|
+
{ atMs: 2000, value: null },
|
|
218
|
+
]}
|
|
219
|
+
windowStartMs={0}
|
|
220
|
+
windowEndMs={2000}
|
|
221
|
+
ariaLabel="Antwortzeit"
|
|
222
|
+
axisLabels={{ start: "vor 24h", end: "jetzt" }}
|
|
223
|
+
/>,
|
|
224
|
+
);
|
|
225
|
+
expect(screen.getByRole("img", { name: "Antwortzeit" })).toBeTruthy();
|
|
226
|
+
expect(container.querySelectorAll("path").length).toBe(2);
|
|
227
|
+
expect(screen.getByText("jetzt")).toBeTruthy();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { type ReactNode, useId } from "react";
|
|
2
|
+
import { STATUS_TONE_TEXT, type StatusTone } from "./status-badge";
|
|
3
|
+
|
|
4
|
+
// Inline-SVG-Charts — kein Chart-Dep. Farben ausschließlich über die
|
|
5
|
+
// --color-status-* / --color-foreground Theme-Tokens; Achsen-Labels
|
|
6
|
+
// kommen translated vom Caller (keine Locale-Annahmen im Widget).
|
|
7
|
+
|
|
8
|
+
const TONE_VAR: Record<StatusTone, string> = {
|
|
9
|
+
ok: "var(--color-status-ok)",
|
|
10
|
+
warn: "var(--color-status-warn)",
|
|
11
|
+
bad: "var(--color-status-bad)",
|
|
12
|
+
critical: "var(--color-status-critical)",
|
|
13
|
+
muted: "var(--color-muted-foreground)",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type StatusBarEntry = {
|
|
17
|
+
/** Stable key (z.B. ISO-Datum). */
|
|
18
|
+
readonly key: string;
|
|
19
|
+
/** Balkenhöhe 0..1 (z.B. operational=1, degraded=0.75, outage=0.25). */
|
|
20
|
+
readonly level: number;
|
|
21
|
+
readonly tone: StatusTone;
|
|
22
|
+
/** Tooltip-Text (<title>) — translated vom Caller. */
|
|
23
|
+
readonly label?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Status-Balkenleiste (z.B. 90-Tage-Uptime): variable-height Bars mit
|
|
27
|
+
* Gradient-Fade + Tick-Line am Bar-Top; der letzte Eintrag bekommt einen
|
|
28
|
+
* „jetzt"-Accent-Stripe. */
|
|
29
|
+
export function StatusBarChart({
|
|
30
|
+
entries,
|
|
31
|
+
ariaLabel,
|
|
32
|
+
startLabel,
|
|
33
|
+
endLabel,
|
|
34
|
+
highlightLast = true,
|
|
35
|
+
testId,
|
|
36
|
+
}: {
|
|
37
|
+
readonly entries: readonly StatusBarEntry[];
|
|
38
|
+
readonly ariaLabel: string;
|
|
39
|
+
/** Achsen-Beschriftung links/rechts unter dem Chart — translated. */
|
|
40
|
+
readonly startLabel?: string;
|
|
41
|
+
readonly endLabel?: string;
|
|
42
|
+
readonly highlightLast?: boolean;
|
|
43
|
+
readonly testId?: string;
|
|
44
|
+
}): ReactNode {
|
|
45
|
+
const gradPrefix = useId();
|
|
46
|
+
if (entries.length === 0) return <div className="h-9" aria-hidden />;
|
|
47
|
+
|
|
48
|
+
const chartHeight = 36;
|
|
49
|
+
const tickHeight = 1;
|
|
50
|
+
const barGap = 1;
|
|
51
|
+
const lastIdx = entries.length - 1;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div data-testid={testId}>
|
|
55
|
+
<svg
|
|
56
|
+
viewBox={`0 0 ${entries.length * (1 + barGap)} ${chartHeight}`}
|
|
57
|
+
preserveAspectRatio="none"
|
|
58
|
+
className="block h-9 w-full"
|
|
59
|
+
role="img"
|
|
60
|
+
aria-label={ariaLabel}
|
|
61
|
+
>
|
|
62
|
+
<title>{ariaLabel}</title>
|
|
63
|
+
{entries.map((entry, idx) => {
|
|
64
|
+
const x = idx * (1 + barGap);
|
|
65
|
+
const level = Math.max(0, Math.min(1, entry.level));
|
|
66
|
+
const barHeight = (chartHeight - tickHeight) * level;
|
|
67
|
+
const barY = chartHeight - barHeight;
|
|
68
|
+
const isLast = highlightLast && idx === lastIdx;
|
|
69
|
+
const color = TONE_VAR[entry.tone];
|
|
70
|
+
const gradId = `${gradPrefix}-${idx}`;
|
|
71
|
+
return (
|
|
72
|
+
<g key={entry.key}>
|
|
73
|
+
<defs>
|
|
74
|
+
<linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
|
|
75
|
+
<stop offset="0%" stopColor={color} stopOpacity={isLast ? 0.85 : 0.5} />
|
|
76
|
+
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
|
|
77
|
+
</linearGradient>
|
|
78
|
+
</defs>
|
|
79
|
+
{isLast && (
|
|
80
|
+
<rect
|
|
81
|
+
x={x - 0.5}
|
|
82
|
+
y={0}
|
|
83
|
+
width={2}
|
|
84
|
+
height={chartHeight}
|
|
85
|
+
fill="var(--color-foreground)"
|
|
86
|
+
fillOpacity={0.06}
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
<rect x={x} y={barY} width={1} height={barHeight} fill={`url(#${gradId})`}>
|
|
90
|
+
{entry.label !== undefined && <title>{entry.label}</title>}
|
|
91
|
+
</rect>
|
|
92
|
+
<rect
|
|
93
|
+
x={x}
|
|
94
|
+
y={barY - tickHeight}
|
|
95
|
+
width={1}
|
|
96
|
+
height={tickHeight}
|
|
97
|
+
fill="var(--color-foreground)"
|
|
98
|
+
fillOpacity={isLast ? 1.0 : 0.7}
|
|
99
|
+
/>
|
|
100
|
+
</g>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</svg>
|
|
104
|
+
{(startLabel !== undefined || endLabel !== undefined) && (
|
|
105
|
+
<div className="mt-0.5 flex justify-between text-[11px] text-muted-foreground">
|
|
106
|
+
<span>{startLabel}</span>
|
|
107
|
+
<span>{endLabel}</span>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Quadratic-durch-Mittelpunkte-Trick: glättet die Zick-Zack-Linie ohne
|
|
115
|
+
* Overshoot (~5 Zeilen statt Catmull-Rom/Bezier-Fit). */
|
|
116
|
+
export function smoothPath(pts: ReadonlyArray<{ readonly x: number; readonly y: number }>): string {
|
|
117
|
+
const first = pts[0];
|
|
118
|
+
if (!first) return "";
|
|
119
|
+
let d = `M ${first.x.toFixed(1)} ${first.y.toFixed(1)}`;
|
|
120
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
121
|
+
const cur = pts[i];
|
|
122
|
+
const next = pts[i + 1];
|
|
123
|
+
if (!cur || !next) break;
|
|
124
|
+
const midX = (cur.x + next.x) / 2;
|
|
125
|
+
const midY = (cur.y + next.y) / 2;
|
|
126
|
+
d += ` Q ${cur.x.toFixed(1)} ${cur.y.toFixed(1)}, ${midX.toFixed(1)} ${midY.toFixed(1)}`;
|
|
127
|
+
}
|
|
128
|
+
const last = pts[pts.length - 1];
|
|
129
|
+
if (!last) return d;
|
|
130
|
+
return `${d} L ${last.x.toFixed(1)} ${last.y.toFixed(1)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type TimeseriesPoint = {
|
|
134
|
+
readonly atMs: number;
|
|
135
|
+
/** null = Ausfall/kein Wert → fällt auf die Grundlinie (sichtbarer Einbruch). */
|
|
136
|
+
readonly value: number | null;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Zeitreihen-Linien-Chart (geglättete Linie + Flächen-Verlauf). x-Achse =
|
|
140
|
+
* ZEIT im Fenster windowStartMs..windowEndMs, nicht Index — 5 Min Daten in
|
|
141
|
+
* einem 30-Tage-Fenster ergeben ehrlich einen schmalen Streifen rechts. */
|
|
142
|
+
export function TimeseriesChart({
|
|
143
|
+
points,
|
|
144
|
+
windowStartMs,
|
|
145
|
+
windowEndMs,
|
|
146
|
+
tone = "ok",
|
|
147
|
+
ariaLabel,
|
|
148
|
+
axisLabels,
|
|
149
|
+
emptyContent,
|
|
150
|
+
testId,
|
|
151
|
+
}: {
|
|
152
|
+
readonly points: readonly TimeseriesPoint[];
|
|
153
|
+
readonly windowStartMs: number;
|
|
154
|
+
readonly windowEndMs: number;
|
|
155
|
+
readonly tone?: StatusTone;
|
|
156
|
+
readonly ariaLabel: string;
|
|
157
|
+
/** Achsen-Zeile unter dem Chart (translated/formatiert vom Caller). */
|
|
158
|
+
readonly axisLabels?: { readonly start: string; readonly mid?: string; readonly end: string };
|
|
159
|
+
/** Rendert statt des Charts wenn <2 Messwerte vorliegen. */
|
|
160
|
+
readonly emptyContent?: ReactNode;
|
|
161
|
+
readonly testId?: string;
|
|
162
|
+
}): ReactNode {
|
|
163
|
+
const gradientId = useId();
|
|
164
|
+
const chartWidth = 300;
|
|
165
|
+
const chartHeight = 64;
|
|
166
|
+
|
|
167
|
+
const values = points.map((p) => p.value).filter((v): v is number => v !== null);
|
|
168
|
+
if (values.length < 2) {
|
|
169
|
+
return (
|
|
170
|
+
<div className="flex h-16 items-center justify-center text-[13px] text-muted-foreground">
|
|
171
|
+
{emptyContent}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const maxValue = Math.max(...values, 1);
|
|
177
|
+
const span = Math.max(1, windowEndMs - windowStartMs);
|
|
178
|
+
const xOf = (atMs: number) =>
|
|
179
|
+
Math.max(0, Math.min(1, (atMs - windowStartMs) / span)) * chartWidth;
|
|
180
|
+
const chartPoints = points.map((p) => ({
|
|
181
|
+
x: xOf(p.atMs),
|
|
182
|
+
y: p.value === null ? chartHeight : chartHeight - (p.value / maxValue) * chartHeight,
|
|
183
|
+
}));
|
|
184
|
+
const linePath = smoothPath(chartPoints);
|
|
185
|
+
const firstPoint = chartPoints[0];
|
|
186
|
+
const lastPoint = chartPoints[chartPoints.length - 1];
|
|
187
|
+
const areaPath =
|
|
188
|
+
firstPoint && lastPoint
|
|
189
|
+
? `${linePath} L ${lastPoint.x.toFixed(1)} ${chartHeight} L ${firstPoint.x.toFixed(1)} ${chartHeight} Z`
|
|
190
|
+
: "";
|
|
191
|
+
const color = TONE_VAR[tone];
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div data-testid={testId} className={STATUS_TONE_TEXT[tone]}>
|
|
195
|
+
<svg
|
|
196
|
+
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
|
197
|
+
preserveAspectRatio="none"
|
|
198
|
+
className="block h-16 w-full"
|
|
199
|
+
role="img"
|
|
200
|
+
aria-label={ariaLabel}
|
|
201
|
+
>
|
|
202
|
+
<title>{ariaLabel}</title>
|
|
203
|
+
<defs>
|
|
204
|
+
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
205
|
+
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
|
206
|
+
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
|
|
207
|
+
</linearGradient>
|
|
208
|
+
</defs>
|
|
209
|
+
<path d={areaPath} fill={`url(#${gradientId})`} stroke="none" />
|
|
210
|
+
<path
|
|
211
|
+
d={linePath}
|
|
212
|
+
fill="none"
|
|
213
|
+
stroke={color}
|
|
214
|
+
strokeWidth={1.5}
|
|
215
|
+
strokeLinejoin="round"
|
|
216
|
+
vectorEffect="non-scaling-stroke"
|
|
217
|
+
/>
|
|
218
|
+
</svg>
|
|
219
|
+
{axisLabels !== undefined && (
|
|
220
|
+
<div className="mt-1 flex justify-between text-[11px] text-muted-foreground">
|
|
221
|
+
<span>{axisLabels.start}</span>
|
|
222
|
+
{axisLabels.mid !== undefined && <span>{axisLabels.mid}</span>}
|
|
223
|
+
<span>{axisLabels.end}</span>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ChevronRight } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
/** Aufklappbare Sektion (native <details>, aber React-kontrolliert: sonst
|
|
5
|
+
* springt das open-Attribut bei jedem Parent-Re-Render auf den Initialwert
|
|
6
|
+
* zurück). Eine Optik für alle „Erweitert"/„Mehr"-Abschnitte. */
|
|
7
|
+
export function CollapsibleSection({
|
|
8
|
+
title,
|
|
9
|
+
defaultOpen = false,
|
|
10
|
+
children,
|
|
11
|
+
testId,
|
|
12
|
+
}: {
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly defaultOpen?: boolean;
|
|
15
|
+
readonly children: ReactNode;
|
|
16
|
+
readonly testId?: string;
|
|
17
|
+
}): ReactNode {
|
|
18
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
19
|
+
// useState(defaultOpen) liest nur den Mount-Wert — ein async hydrierter
|
|
20
|
+
// Draft flippt `defaultOpen` zu spät für den Lazy-Initializer. Open-only
|
|
21
|
+
// Effect: kämpft nie gegen ein manuelles Zuklappen des Users.
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (defaultOpen) setOpen(true);
|
|
24
|
+
}, [defaultOpen]);
|
|
25
|
+
return (
|
|
26
|
+
<details
|
|
27
|
+
data-testid={testId}
|
|
28
|
+
className="group rounded-lg border bg-card"
|
|
29
|
+
open={open}
|
|
30
|
+
onToggle={(e) => setOpen(e.currentTarget.open)}
|
|
31
|
+
>
|
|
32
|
+
<summary className="flex cursor-pointer select-none items-center justify-between gap-2 rounded-lg px-4 py-2.5 text-sm font-medium hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
|
|
33
|
+
<span>{title}</span>
|
|
34
|
+
<ChevronRight
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
className="size-4 text-muted-foreground transition-transform group-open:rotate-90"
|
|
37
|
+
/>
|
|
38
|
+
</summary>
|
|
39
|
+
<div className="border-t px-4 py-3">{children}</div>
|
|
40
|
+
</details>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Read-only Schlüssel-Wert-Liste für Detail-Masken (Label links gedimmt,
|
|
4
|
+
* Wert rechts). Wert ist ReactNode → Badges/Chips möglich. */
|
|
5
|
+
export function DetailList({
|
|
6
|
+
rows,
|
|
7
|
+
testId,
|
|
8
|
+
}: {
|
|
9
|
+
readonly rows: readonly { readonly label: string; readonly value: ReactNode }[];
|
|
10
|
+
readonly testId?: string;
|
|
11
|
+
}): ReactNode {
|
|
12
|
+
return (
|
|
13
|
+
<dl data-testid={testId} className="flex flex-col divide-y">
|
|
14
|
+
{rows.map((row) => (
|
|
15
|
+
<div
|
|
16
|
+
key={row.label}
|
|
17
|
+
className="grid grid-cols-1 gap-0.5 py-2.5 sm:grid-cols-[200px_1fr] sm:gap-4"
|
|
18
|
+
>
|
|
19
|
+
<dt className="text-sm text-muted-foreground">{row.label}</dt>
|
|
20
|
+
<dd className="text-sm font-medium">{row.value}</dd>
|
|
21
|
+
</div>
|
|
22
|
+
))}
|
|
23
|
+
</dl>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Mid-Level-Widgets — Kompositionen über den Primitives (Card, DataTable,
|
|
2
|
+
// Banner) + Theme-Tokens. Für Custom-Screens: erst hier schauen, dann bauen.
|
|
3
|
+
// Katalog: docs.kumiko.rocks → Guides → Widgets; visueller Überblick im
|
|
4
|
+
// styleguide-Sample.
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
StatusBarChart,
|
|
8
|
+
type StatusBarEntry,
|
|
9
|
+
smoothPath,
|
|
10
|
+
TimeseriesChart,
|
|
11
|
+
type TimeseriesPoint,
|
|
12
|
+
} from "./charts";
|
|
13
|
+
export { CollapsibleSection } from "./collapsible-section";
|
|
14
|
+
export { DetailList } from "./detail-list";
|
|
15
|
+
export { ModeSwitch } from "./mode-switch";
|
|
16
|
+
export { ProgressBar } from "./progress-bar";
|
|
17
|
+
export { QueryTable, type QueryTableColumn, type QueryTableProps } from "./query-table";
|
|
18
|
+
export { SectionCard } from "./section-card";
|
|
19
|
+
export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
|
|
20
|
+
export { EmptyState, ErrorState, LoadingState } from "./states";
|
|
21
|
+
export { STATUS_TONE_TEXT, StatusBadge, type StatusTone } from "./status-badge";
|