@cosmicdrift/kumiko-renderer-web 0.137.0 → 0.139.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 +16 -0
- package/src/primitives/index.tsx +21 -0
- package/src/widgets/__tests__/form-kit.test.tsx +162 -2
- package/src/widgets/form-fields.tsx +269 -0
- package/src/widgets/index.ts +27 -2
- package/src/widgets/result-panel.tsx +68 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.139.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.139.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.139.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.139.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
|
@@ -155,24 +155,36 @@ export {
|
|
|
155
155
|
} from "./tokens";
|
|
156
156
|
export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
|
|
157
157
|
export type {
|
|
158
|
+
BooleanFieldProps,
|
|
159
|
+
ComparisonMetric,
|
|
160
|
+
DateFieldProps,
|
|
158
161
|
FeedRow,
|
|
162
|
+
FileFieldProps,
|
|
159
163
|
NumberFieldProps,
|
|
160
164
|
ProgressListRow,
|
|
161
165
|
QueryTableColumn,
|
|
162
166
|
QueryTableProps,
|
|
167
|
+
RangeFieldProps,
|
|
163
168
|
ResultColumn,
|
|
169
|
+
SelectFieldProps,
|
|
164
170
|
StatDelta,
|
|
165
171
|
StatTone,
|
|
166
172
|
StatusBarEntry,
|
|
167
173
|
StatusTone,
|
|
174
|
+
TextareaFieldProps,
|
|
175
|
+
TextFieldProps,
|
|
168
176
|
TimeseriesPoint,
|
|
169
177
|
} from "./widgets";
|
|
170
178
|
export {
|
|
179
|
+
BooleanField,
|
|
171
180
|
CollapsibleSection,
|
|
181
|
+
ComparisonTable,
|
|
182
|
+
DateField,
|
|
172
183
|
DetailList,
|
|
173
184
|
EmptyState,
|
|
174
185
|
ErrorState,
|
|
175
186
|
FeedList,
|
|
187
|
+
FileField,
|
|
176
188
|
LoadingState,
|
|
177
189
|
MiniStat,
|
|
178
190
|
ModeSwitch,
|
|
@@ -182,15 +194,19 @@ export {
|
|
|
182
194
|
ProgressBar,
|
|
183
195
|
ProgressList,
|
|
184
196
|
QueryTable,
|
|
197
|
+
RangeField,
|
|
185
198
|
ResultPanel,
|
|
186
199
|
ResultTable,
|
|
187
200
|
SectionCard,
|
|
201
|
+
SelectField,
|
|
188
202
|
Sparkline,
|
|
189
203
|
STATUS_TONE_TEXT,
|
|
190
204
|
StatCard,
|
|
191
205
|
StatusBadge,
|
|
192
206
|
StatusBarChart,
|
|
193
207
|
smoothPath,
|
|
208
|
+
TextareaField,
|
|
209
|
+
TextField,
|
|
194
210
|
TimeseriesChart,
|
|
195
211
|
useDraft,
|
|
196
212
|
} from "./widgets";
|
package/src/primitives/index.tsx
CHANGED
|
@@ -111,12 +111,19 @@ const BUTTON_VARIANT = {
|
|
|
111
111
|
link: "link",
|
|
112
112
|
} as const;
|
|
113
113
|
|
|
114
|
+
const BUTTON_SIZE = {
|
|
115
|
+
sm: "sm",
|
|
116
|
+
md: "default",
|
|
117
|
+
icon: "icon",
|
|
118
|
+
} as const;
|
|
119
|
+
|
|
114
120
|
function DefaultButton({
|
|
115
121
|
type = "button",
|
|
116
122
|
onClick,
|
|
117
123
|
disabled,
|
|
118
124
|
loading,
|
|
119
125
|
variant = "primary",
|
|
126
|
+
size = "md",
|
|
120
127
|
children,
|
|
121
128
|
testId,
|
|
122
129
|
}: ButtonProps): ReactNode {
|
|
@@ -128,6 +135,7 @@ function DefaultButton({
|
|
|
128
135
|
data-testid={testId}
|
|
129
136
|
data-loading={loading === true ? "true" : undefined}
|
|
130
137
|
variant={BUTTON_VARIANT[variant]}
|
|
138
|
+
size={BUTTON_SIZE[size]}
|
|
131
139
|
// link-Variant rendert text-artig (Inline-Link im Fließtext/Banner),
|
|
132
140
|
// nicht als gepolsterte Buttonfläche.
|
|
133
141
|
className={variant === "link" ? "h-auto px-0 py-0" : undefined}
|
|
@@ -292,6 +300,19 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
292
300
|
className="text-right tabular-nums"
|
|
293
301
|
/>
|
|
294
302
|
);
|
|
303
|
+
case "range":
|
|
304
|
+
return (
|
|
305
|
+
<input
|
|
306
|
+
type="range"
|
|
307
|
+
{...common}
|
|
308
|
+
min={props.min}
|
|
309
|
+
max={props.max}
|
|
310
|
+
step={props.step ?? 1}
|
|
311
|
+
value={props.value}
|
|
312
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(Number(e.target.value))}
|
|
313
|
+
className="w-full accent-primary"
|
|
314
|
+
/>
|
|
315
|
+
);
|
|
295
316
|
case "boolean":
|
|
296
317
|
return (
|
|
297
318
|
<Checkbox
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
2
4
|
import { act, fireEvent, render, renderHook, screen } from "../../__tests__/test-utils";
|
|
3
5
|
import { DetailList } from "../detail-list";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
+
import {
|
|
7
|
+
BooleanField,
|
|
8
|
+
DateField,
|
|
9
|
+
FileField,
|
|
10
|
+
MoneyField,
|
|
11
|
+
NumberField,
|
|
12
|
+
PercentField,
|
|
13
|
+
RangeField,
|
|
14
|
+
SelectField,
|
|
15
|
+
TextareaField,
|
|
16
|
+
TextField,
|
|
17
|
+
} from "../form-fields";
|
|
18
|
+
import { ComparisonTable, ResultPanel, ResultTable } from "../result-panel";
|
|
6
19
|
import { useDraft } from "../use-draft";
|
|
7
20
|
|
|
8
21
|
describe("useDraft", () => {
|
|
@@ -103,3 +116,150 @@ describe("ResultTable", () => {
|
|
|
103
116
|
expect(screen.getByText("300.000 €").className).toContain("tabular-nums");
|
|
104
117
|
});
|
|
105
118
|
});
|
|
119
|
+
|
|
120
|
+
describe("Feld-Widgets (Select/Date/Text/Boolean/Textarea)", () => {
|
|
121
|
+
const noopStr = (): void => {};
|
|
122
|
+
|
|
123
|
+
test("SelectField rendert Label und den aktuell gewählten Wert", () => {
|
|
124
|
+
render(
|
|
125
|
+
<SelectField
|
|
126
|
+
label="Land"
|
|
127
|
+
id="l"
|
|
128
|
+
name="l"
|
|
129
|
+
value="NW"
|
|
130
|
+
onChange={noopStr}
|
|
131
|
+
options={[
|
|
132
|
+
{ value: "NW", label: "Nordrhein-Westfalen" },
|
|
133
|
+
{ value: "BY", label: "Bayern" },
|
|
134
|
+
]}
|
|
135
|
+
/>,
|
|
136
|
+
);
|
|
137
|
+
expect(screen.getByText("Land")).toBeTruthy();
|
|
138
|
+
expect(screen.getByText("Nordrhein-Westfalen")).toBeTruthy();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("TextField meldet Eingabe", () => {
|
|
142
|
+
const onChange = mock();
|
|
143
|
+
render(<TextField label="Name" id="n" name="n" value="A" onChange={onChange} />);
|
|
144
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Neu" } });
|
|
145
|
+
expect(onChange).toHaveBeenCalledWith("Neu");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("DateField rendert mit Wert", () => {
|
|
149
|
+
render(
|
|
150
|
+
<DateField
|
|
151
|
+
label="Datum"
|
|
152
|
+
id="d"
|
|
153
|
+
name="d"
|
|
154
|
+
value="2026-07-10"
|
|
155
|
+
onChange={noopStr}
|
|
156
|
+
max="2030-01-01"
|
|
157
|
+
/>,
|
|
158
|
+
);
|
|
159
|
+
expect(screen.getByText("Datum")).toBeTruthy();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("BooleanField meldet Umschalten", () => {
|
|
163
|
+
const onChange = mock();
|
|
164
|
+
render(<BooleanField label="Aktiv" id="b" name="b" value={false} onChange={onChange} />);
|
|
165
|
+
fireEvent.click(screen.getByRole("checkbox"));
|
|
166
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("TextareaField meldet Eingabe", () => {
|
|
170
|
+
const onChange = mock();
|
|
171
|
+
render(<TextareaField label="Notiz" id="t" name="t" value="" onChange={onChange} rows={3} />);
|
|
172
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Text" } });
|
|
173
|
+
expect(onChange).toHaveBeenCalledWith("Text");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("ResultPanel footer", () => {
|
|
178
|
+
test("rendert den footer-Slot", () => {
|
|
179
|
+
render(
|
|
180
|
+
<ResultPanel
|
|
181
|
+
title="R"
|
|
182
|
+
rows={[{ label: "X", value: "1" }]}
|
|
183
|
+
footer={<button type="button">Los</button>}
|
|
184
|
+
>
|
|
185
|
+
<span>body</span>
|
|
186
|
+
</ResultPanel>,
|
|
187
|
+
);
|
|
188
|
+
expect(screen.getByRole("button", { name: "Los" })).toBeTruthy();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("RangeField", () => {
|
|
193
|
+
test("meldet den Slider-Wert als Zahl", () => {
|
|
194
|
+
const onChange = mock();
|
|
195
|
+
render(
|
|
196
|
+
<RangeField
|
|
197
|
+
label="Abruf"
|
|
198
|
+
id="r"
|
|
199
|
+
name="r"
|
|
200
|
+
value={20}
|
|
201
|
+
min={0}
|
|
202
|
+
max={100}
|
|
203
|
+
step={5}
|
|
204
|
+
onChange={onChange}
|
|
205
|
+
/>,
|
|
206
|
+
);
|
|
207
|
+
expect(screen.getByText("Abruf")).toBeTruthy();
|
|
208
|
+
fireEvent.change(screen.getByRole("slider"), { target: { value: "45" } });
|
|
209
|
+
expect(onChange).toHaveBeenCalledWith(45);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("FileField", () => {
|
|
214
|
+
test("rendert Label", () => {
|
|
215
|
+
render(
|
|
216
|
+
<FileField label="Bild" id="f" name="f" value={null} onChange={() => {}} variant="image" />,
|
|
217
|
+
);
|
|
218
|
+
expect(screen.getByText("Bild")).toBeTruthy();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("Button size", () => {
|
|
223
|
+
function SizedButton({ size }: { readonly size: "sm" | "md" | "icon" }): ReactNode {
|
|
224
|
+
const { Button } = usePrimitives();
|
|
225
|
+
return (
|
|
226
|
+
<Button size={size} onClick={() => {}}>
|
|
227
|
+
X
|
|
228
|
+
</Button>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
test("size=sm rendert die kompakte shadcn-Größe (h-8)", () => {
|
|
232
|
+
render(<SizedButton size="sm" />);
|
|
233
|
+
expect(screen.getByRole("button", { name: "X" }).className).toContain("h-8");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("ComparisonTable", () => {
|
|
238
|
+
test("transponierte Matrix mit Best-Highlight je Kennzahl", () => {
|
|
239
|
+
const cols = [
|
|
240
|
+
{ name: "A", rate: 900 },
|
|
241
|
+
{ name: "B", rate: 850 },
|
|
242
|
+
];
|
|
243
|
+
render(
|
|
244
|
+
<ComparisonTable
|
|
245
|
+
testId="cmp"
|
|
246
|
+
columns={cols}
|
|
247
|
+
columnHeader={(c) => c.name}
|
|
248
|
+
columnKey={(c) => c.name}
|
|
249
|
+
metricLabel="Kennzahl"
|
|
250
|
+
metrics={[
|
|
251
|
+
{
|
|
252
|
+
label: "Rate",
|
|
253
|
+
value: (c: { name: string; rate: number }) => `${c.rate} €`,
|
|
254
|
+
bestIndex: (cs) => ((cs[0]?.rate ?? 0) <= (cs[1]?.rate ?? 0) ? 0 : 1),
|
|
255
|
+
},
|
|
256
|
+
]}
|
|
257
|
+
/>,
|
|
258
|
+
);
|
|
259
|
+
expect(screen.getByText("Kennzahl")).toBeTruthy();
|
|
260
|
+
expect(screen.getByText("A")).toBeTruthy();
|
|
261
|
+
// B (850) ist günstiger → hervorgehoben (font-semibold text-primary span)
|
|
262
|
+
expect(screen.getByText("850 €").className).toContain("text-primary");
|
|
263
|
+
expect(screen.getByText("900 €").className).not.toContain("text-primary");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -52,3 +52,272 @@ export function MoneyField(props: NumberFieldProps): ReactNode {
|
|
|
52
52
|
export function PercentField(props: NumberFieldProps): ReactNode {
|
|
53
53
|
return <NumberField {...props} />;
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
interface FieldBase {
|
|
57
|
+
readonly label: string;
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly required?: boolean;
|
|
61
|
+
readonly disabled?: boolean;
|
|
62
|
+
readonly testId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TextFieldProps extends FieldBase {
|
|
66
|
+
readonly value: string;
|
|
67
|
+
readonly onChange: (v: string) => void;
|
|
68
|
+
readonly placeholder?: string;
|
|
69
|
+
readonly autoComplete?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Textfeld = Field + Input(kind:"text"). */
|
|
73
|
+
export function TextField({
|
|
74
|
+
label,
|
|
75
|
+
id,
|
|
76
|
+
name,
|
|
77
|
+
value,
|
|
78
|
+
onChange,
|
|
79
|
+
required,
|
|
80
|
+
disabled,
|
|
81
|
+
placeholder,
|
|
82
|
+
autoComplete,
|
|
83
|
+
testId,
|
|
84
|
+
}: TextFieldProps): ReactNode {
|
|
85
|
+
const { Field, Input } = usePrimitives();
|
|
86
|
+
return (
|
|
87
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
88
|
+
<Input
|
|
89
|
+
kind="text"
|
|
90
|
+
id={id}
|
|
91
|
+
name={name}
|
|
92
|
+
value={value}
|
|
93
|
+
onChange={onChange}
|
|
94
|
+
required={required}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
{...(placeholder !== undefined && { placeholder })}
|
|
97
|
+
{...(autoComplete !== undefined && { autoComplete })}
|
|
98
|
+
/>
|
|
99
|
+
</Field>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface SelectFieldProps extends FieldBase {
|
|
104
|
+
readonly value: string;
|
|
105
|
+
readonly onChange: (v: string) => void;
|
|
106
|
+
readonly options:
|
|
107
|
+
| readonly string[]
|
|
108
|
+
| readonly { readonly value: string; readonly label: string }[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Auswahlfeld = Field + Input(kind:"select"). */
|
|
112
|
+
export function SelectField({
|
|
113
|
+
label,
|
|
114
|
+
id,
|
|
115
|
+
name,
|
|
116
|
+
value,
|
|
117
|
+
onChange,
|
|
118
|
+
options,
|
|
119
|
+
required,
|
|
120
|
+
disabled,
|
|
121
|
+
testId,
|
|
122
|
+
}: SelectFieldProps): ReactNode {
|
|
123
|
+
const { Field, Input } = usePrimitives();
|
|
124
|
+
return (
|
|
125
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
126
|
+
<Input
|
|
127
|
+
kind="select"
|
|
128
|
+
id={id}
|
|
129
|
+
name={name}
|
|
130
|
+
value={value}
|
|
131
|
+
onChange={onChange}
|
|
132
|
+
options={options}
|
|
133
|
+
required={required}
|
|
134
|
+
disabled={disabled}
|
|
135
|
+
/>
|
|
136
|
+
</Field>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface DateFieldProps extends FieldBase {
|
|
141
|
+
readonly value: string;
|
|
142
|
+
readonly onChange: (v: string | undefined) => void;
|
|
143
|
+
readonly min?: string;
|
|
144
|
+
readonly max?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Datumsfeld = Field + Input(kind:"date"), ISO yyyy-mm-dd. */
|
|
148
|
+
export function DateField({
|
|
149
|
+
label,
|
|
150
|
+
id,
|
|
151
|
+
name,
|
|
152
|
+
value,
|
|
153
|
+
onChange,
|
|
154
|
+
min,
|
|
155
|
+
max,
|
|
156
|
+
required,
|
|
157
|
+
disabled,
|
|
158
|
+
testId,
|
|
159
|
+
}: DateFieldProps): ReactNode {
|
|
160
|
+
const { Field, Input } = usePrimitives();
|
|
161
|
+
return (
|
|
162
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
163
|
+
<Input
|
|
164
|
+
kind="date"
|
|
165
|
+
id={id}
|
|
166
|
+
name={name}
|
|
167
|
+
value={value}
|
|
168
|
+
onChange={onChange}
|
|
169
|
+
required={required}
|
|
170
|
+
disabled={disabled}
|
|
171
|
+
{...(min !== undefined && { min })}
|
|
172
|
+
{...(max !== undefined && { max })}
|
|
173
|
+
/>
|
|
174
|
+
</Field>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface BooleanFieldProps extends FieldBase {
|
|
179
|
+
readonly value: boolean;
|
|
180
|
+
readonly onChange: (v: boolean) => void;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Checkbox/Switch = Field(layout:"inline") + Input(kind:"boolean"). */
|
|
184
|
+
export function BooleanField({
|
|
185
|
+
label,
|
|
186
|
+
id,
|
|
187
|
+
name,
|
|
188
|
+
value,
|
|
189
|
+
onChange,
|
|
190
|
+
required,
|
|
191
|
+
disabled,
|
|
192
|
+
testId,
|
|
193
|
+
}: BooleanFieldProps): ReactNode {
|
|
194
|
+
const { Field, Input } = usePrimitives();
|
|
195
|
+
return (
|
|
196
|
+
<Field id={id} label={label} required={required} layout="inline" testId={testId}>
|
|
197
|
+
<Input
|
|
198
|
+
kind="boolean"
|
|
199
|
+
id={id}
|
|
200
|
+
name={name}
|
|
201
|
+
value={value}
|
|
202
|
+
onChange={onChange}
|
|
203
|
+
required={required}
|
|
204
|
+
disabled={disabled}
|
|
205
|
+
/>
|
|
206
|
+
</Field>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface TextareaFieldProps extends FieldBase {
|
|
211
|
+
readonly value: string;
|
|
212
|
+
readonly onChange: (v: string) => void;
|
|
213
|
+
readonly rows?: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Mehrzeiliges Textfeld = Field + Input(kind:"textarea"). */
|
|
217
|
+
export function TextareaField({
|
|
218
|
+
label,
|
|
219
|
+
id,
|
|
220
|
+
name,
|
|
221
|
+
value,
|
|
222
|
+
onChange,
|
|
223
|
+
rows,
|
|
224
|
+
required,
|
|
225
|
+
disabled,
|
|
226
|
+
testId,
|
|
227
|
+
}: TextareaFieldProps): ReactNode {
|
|
228
|
+
const { Field, Input } = usePrimitives();
|
|
229
|
+
return (
|
|
230
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
231
|
+
<Input
|
|
232
|
+
kind="textarea"
|
|
233
|
+
id={id}
|
|
234
|
+
name={name}
|
|
235
|
+
value={value}
|
|
236
|
+
onChange={onChange}
|
|
237
|
+
required={required}
|
|
238
|
+
disabled={disabled}
|
|
239
|
+
{...(rows !== undefined && { rows })}
|
|
240
|
+
/>
|
|
241
|
+
</Field>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface RangeFieldProps extends FieldBase {
|
|
246
|
+
readonly value: number;
|
|
247
|
+
readonly onChange: (v: number) => void;
|
|
248
|
+
readonly min: number;
|
|
249
|
+
readonly max: number;
|
|
250
|
+
readonly step?: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Schieberegler = Field + Input(kind:"range"). */
|
|
254
|
+
export function RangeField({
|
|
255
|
+
label,
|
|
256
|
+
id,
|
|
257
|
+
name,
|
|
258
|
+
value,
|
|
259
|
+
onChange,
|
|
260
|
+
min,
|
|
261
|
+
max,
|
|
262
|
+
step,
|
|
263
|
+
required,
|
|
264
|
+
disabled,
|
|
265
|
+
testId,
|
|
266
|
+
}: RangeFieldProps): ReactNode {
|
|
267
|
+
const { Field, Input } = usePrimitives();
|
|
268
|
+
return (
|
|
269
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
270
|
+
<Input
|
|
271
|
+
kind="range"
|
|
272
|
+
id={id}
|
|
273
|
+
name={name}
|
|
274
|
+
value={value}
|
|
275
|
+
onChange={onChange}
|
|
276
|
+
min={min}
|
|
277
|
+
max={max}
|
|
278
|
+
required={required}
|
|
279
|
+
disabled={disabled}
|
|
280
|
+
{...(step !== undefined && { step })}
|
|
281
|
+
/>
|
|
282
|
+
</Field>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface FileFieldProps extends FieldBase {
|
|
287
|
+
/** FileRef-UUID der gespeicherten Datei, oder null. */
|
|
288
|
+
readonly value: string | null;
|
|
289
|
+
readonly onChange: (fileId: string | null) => void;
|
|
290
|
+
readonly accept?: readonly string[];
|
|
291
|
+
/** "image" zeigt eine Vorschau, "file" nur den Dateinamen. Default "file". */
|
|
292
|
+
readonly variant?: "file" | "image";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Datei-Upload = Field + Input(kind:"file"|"image") — FileRef-basiert. */
|
|
296
|
+
export function FileField({
|
|
297
|
+
label,
|
|
298
|
+
id,
|
|
299
|
+
name,
|
|
300
|
+
value,
|
|
301
|
+
onChange,
|
|
302
|
+
accept,
|
|
303
|
+
variant = "file",
|
|
304
|
+
required,
|
|
305
|
+
disabled,
|
|
306
|
+
testId,
|
|
307
|
+
}: FileFieldProps): ReactNode {
|
|
308
|
+
const { Field, Input } = usePrimitives();
|
|
309
|
+
return (
|
|
310
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
311
|
+
<Input
|
|
312
|
+
kind={variant}
|
|
313
|
+
id={id}
|
|
314
|
+
name={name}
|
|
315
|
+
value={value}
|
|
316
|
+
onChange={onChange}
|
|
317
|
+
required={required}
|
|
318
|
+
disabled={disabled}
|
|
319
|
+
{...(accept !== undefined && { accept })}
|
|
320
|
+
/>
|
|
321
|
+
</Field>
|
|
322
|
+
);
|
|
323
|
+
}
|
package/src/widgets/index.ts
CHANGED
|
@@ -13,12 +13,37 @@ 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 {
|
|
16
|
+
export {
|
|
17
|
+
BooleanField,
|
|
18
|
+
type BooleanFieldProps,
|
|
19
|
+
DateField,
|
|
20
|
+
type DateFieldProps,
|
|
21
|
+
FileField,
|
|
22
|
+
type FileFieldProps,
|
|
23
|
+
MoneyField,
|
|
24
|
+
NumberField,
|
|
25
|
+
type NumberFieldProps,
|
|
26
|
+
PercentField,
|
|
27
|
+
RangeField,
|
|
28
|
+
type RangeFieldProps,
|
|
29
|
+
SelectField,
|
|
30
|
+
type SelectFieldProps,
|
|
31
|
+
TextareaField,
|
|
32
|
+
type TextareaFieldProps,
|
|
33
|
+
TextField,
|
|
34
|
+
type TextFieldProps,
|
|
35
|
+
} from "./form-fields";
|
|
17
36
|
export { ModeSwitch } from "./mode-switch";
|
|
18
37
|
export { ProgressBar } from "./progress-bar";
|
|
19
38
|
export { ProgressList, type ProgressListRow } from "./progress-list";
|
|
20
39
|
export { QueryTable, type QueryTableColumn, type QueryTableProps } from "./query-table";
|
|
21
|
-
export {
|
|
40
|
+
export {
|
|
41
|
+
type ComparisonMetric,
|
|
42
|
+
ComparisonTable,
|
|
43
|
+
type ResultColumn,
|
|
44
|
+
ResultPanel,
|
|
45
|
+
ResultTable,
|
|
46
|
+
} from "./result-panel";
|
|
22
47
|
export { SectionCard } from "./section-card";
|
|
23
48
|
export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
|
|
24
49
|
export { EmptyState, ErrorState, LoadingState } from "./states";
|
|
@@ -13,6 +13,7 @@ export function ResultPanel({
|
|
|
13
13
|
empty,
|
|
14
14
|
emptyText,
|
|
15
15
|
rows,
|
|
16
|
+
footer,
|
|
16
17
|
children,
|
|
17
18
|
testId,
|
|
18
19
|
}: {
|
|
@@ -25,12 +26,14 @@ export function ResultPanel({
|
|
|
25
26
|
readonly value: ReactNode;
|
|
26
27
|
readonly emphasize?: boolean;
|
|
27
28
|
}[];
|
|
29
|
+
/** Action-Slot am Karten-Fuß (z.B. „In Finanzierung übernehmen"). */
|
|
30
|
+
readonly footer?: ReactNode;
|
|
28
31
|
readonly children?: ReactNode;
|
|
29
32
|
readonly testId?: string;
|
|
30
33
|
}): ReactNode {
|
|
31
34
|
const { Banner } = usePrimitives();
|
|
32
35
|
return (
|
|
33
|
-
<SectionCard title={title} subtitle={subtitle} testId={testId}>
|
|
36
|
+
<SectionCard title={title} subtitle={subtitle} footer={footer} testId={testId}>
|
|
34
37
|
{empty === true ? (
|
|
35
38
|
<Banner variant="info" padded>
|
|
36
39
|
{emptyText}
|
|
@@ -99,3 +102,67 @@ export function ResultTable<Row>({
|
|
|
99
102
|
</div>
|
|
100
103
|
);
|
|
101
104
|
}
|
|
105
|
+
|
|
106
|
+
export interface ComparisonMetric<Col> {
|
|
107
|
+
readonly label: string;
|
|
108
|
+
readonly value: (col: Col, index: number) => ReactNode;
|
|
109
|
+
/** Index der besten Spalte für diese Zeile (hervorgehoben); -1 = keine. */
|
|
110
|
+
readonly bestIndex?: (cols: readonly Col[]) => number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Transponierte Vergleichstabelle: Zeile = Kennzahl, Spalte = Variante, je
|
|
114
|
+
* Kennzahl optional die beste Spalte hervorgehoben. Für Szenario-/Angebots-
|
|
115
|
+
* Vergleiche, wo ResultTable (Zeile=Datensatz) nicht passt. */
|
|
116
|
+
export function ComparisonTable<Col>({
|
|
117
|
+
columns,
|
|
118
|
+
columnHeader,
|
|
119
|
+
columnKey,
|
|
120
|
+
metrics,
|
|
121
|
+
metricLabel,
|
|
122
|
+
testId,
|
|
123
|
+
}: {
|
|
124
|
+
readonly columns: readonly Col[];
|
|
125
|
+
readonly columnHeader: (col: Col, index: number) => string;
|
|
126
|
+
readonly columnKey: (col: Col, index: number) => string;
|
|
127
|
+
readonly metrics: readonly ComparisonMetric<Col>[];
|
|
128
|
+
readonly metricLabel: string;
|
|
129
|
+
readonly testId?: string;
|
|
130
|
+
}): ReactNode {
|
|
131
|
+
return (
|
|
132
|
+
<div className="overflow-x-auto">
|
|
133
|
+
<table data-testid={testId} className="w-full min-w-[24rem] text-sm">
|
|
134
|
+
<thead>
|
|
135
|
+
<tr className="border-b text-left text-muted-foreground">
|
|
136
|
+
<th className="py-1.5 font-medium">{metricLabel}</th>
|
|
137
|
+
{columns.map((col, i) => (
|
|
138
|
+
<th key={columnKey(col, i)} className="py-1.5 text-right font-medium">
|
|
139
|
+
{columnHeader(col, i)}
|
|
140
|
+
</th>
|
|
141
|
+
))}
|
|
142
|
+
</tr>
|
|
143
|
+
</thead>
|
|
144
|
+
<tbody>
|
|
145
|
+
{metrics.map((metric) => {
|
|
146
|
+
const best = metric.bestIndex !== undefined ? metric.bestIndex(columns) : -1;
|
|
147
|
+
return (
|
|
148
|
+
<tr key={metric.label} className="border-b last:border-0">
|
|
149
|
+
<td className="py-1.5 text-muted-foreground">{metric.label}</td>
|
|
150
|
+
{columns.map((col, i) => (
|
|
151
|
+
<td key={columnKey(col, i)} className="py-1.5 text-right tabular-nums">
|
|
152
|
+
{i === best ? (
|
|
153
|
+
<span className="inline-block rounded bg-primary/10 px-2 py-0.5 font-semibold text-primary">
|
|
154
|
+
{metric.value(col, i)}
|
|
155
|
+
</span>
|
|
156
|
+
) : (
|
|
157
|
+
metric.value(col, i)
|
|
158
|
+
)}
|
|
159
|
+
</td>
|
|
160
|
+
))}
|
|
161
|
+
</tr>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
</tbody>
|
|
165
|
+
</table>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|