@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.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__/action-menu.test.tsx +75 -0
- package/src/__tests__/breadcrumb.test.tsx +50 -0
- package/src/__tests__/create-app.test.tsx +171 -1
- package/src/__tests__/dashboard-screen.test.tsx +393 -0
- package/src/__tests__/default-app-shell.test.tsx +47 -1
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/kumiko-screen.test.tsx +155 -1
- package/src/__tests__/lightbox.test.tsx +46 -0
- package/src/__tests__/nav-tree.test.tsx +122 -0
- package/src/__tests__/primitives.test.tsx +32 -1
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/projection-detail.test.tsx +101 -0
- package/src/__tests__/projection-list-actions.test.tsx +131 -0
- package/src/__tests__/render-list.test.tsx +11 -3
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/__tests__/workspace-shell.test.tsx +102 -47
- package/src/__tests__/xss-safety.test.tsx +62 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/browser-locale.ts +1 -1
- package/src/app/create-app.tsx +96 -29
- package/src/app/dashboard-body.tsx +454 -0
- package/src/index.ts +75 -1
- package/src/layout/__tests__/nav-registry.test.ts +105 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
- package/src/layout/default-app-shell.tsx +9 -71
- package/src/layout/nav-tree.tsx +167 -37
- package/src/layout/shell-breadcrumb.ts +46 -0
- package/src/layout/shell-header.tsx +114 -0
- package/src/layout/workspace-shell.tsx +45 -18
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/input-testid.test.tsx +45 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/dialog.tsx +47 -76
- package/src/primitives/index.tsx +215 -37
- package/src/primitives/layout.tsx +39 -0
- package/src/primitives/lightbox.tsx +39 -0
- package/src/primitives/modal-shell.tsx +62 -0
- package/src/primitives/money-input.tsx +20 -10
- package/src/primitives/toast.tsx +14 -9
- package/src/sse/live-events.ts +6 -1
- package/src/styles.css +25 -0
- package/src/tokens.ts +4 -0
- package/src/ui/__tests__/avatar.test.tsx +66 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +115 -0
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/form-kit.test.tsx +368 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/result-panel.test.tsx +117 -0
- package/src/widgets/__tests__/widgets.test.tsx +243 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +43 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +326 -0
- package/src/widgets/index.ts +59 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/mode-switch.tsx +41 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/result-panel.tsx +251 -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
- package/src/widgets/use-draft.ts +40 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
- /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Sheet,
|
|
4
|
+
SheetContent,
|
|
5
|
+
SheetDescription,
|
|
6
|
+
SheetFooter,
|
|
7
|
+
SheetHeader,
|
|
8
|
+
SheetTitle,
|
|
9
|
+
} from "../ui/sheet";
|
|
10
|
+
|
|
11
|
+
export type DrawerProps = {
|
|
12
|
+
readonly open: boolean;
|
|
13
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
14
|
+
readonly side?: "left" | "right" | "top" | "bottom";
|
|
15
|
+
readonly title?: ReactNode;
|
|
16
|
+
readonly description?: ReactNode;
|
|
17
|
+
readonly footer?: ReactNode;
|
|
18
|
+
readonly children: ReactNode;
|
|
19
|
+
readonly testId?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Slide-in panel beside a list (e.g. mail reader next to the inbox) —
|
|
23
|
+
* thin wrapper over the Sheet primitive with header/body/footer slots
|
|
24
|
+
* so screens skip per-route Radix boilerplate. */
|
|
25
|
+
export function Drawer({
|
|
26
|
+
open,
|
|
27
|
+
onOpenChange,
|
|
28
|
+
side = "right",
|
|
29
|
+
title,
|
|
30
|
+
description,
|
|
31
|
+
footer,
|
|
32
|
+
children,
|
|
33
|
+
testId,
|
|
34
|
+
}: DrawerProps): ReactNode {
|
|
35
|
+
return (
|
|
36
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
37
|
+
<SheetContent side={side} data-testid={testId}>
|
|
38
|
+
{(title !== undefined || description !== undefined) && (
|
|
39
|
+
<SheetHeader>
|
|
40
|
+
{title !== undefined && <SheetTitle>{title}</SheetTitle>}
|
|
41
|
+
{description !== undefined && <SheetDescription>{description}</SheetDescription>}
|
|
42
|
+
</SheetHeader>
|
|
43
|
+
)}
|
|
44
|
+
<div className="flex-1 overflow-y-auto px-4">{children}</div>
|
|
45
|
+
{footer !== undefined && <SheetFooter>{footer}</SheetFooter>}
|
|
46
|
+
</SheetContent>
|
|
47
|
+
</Sheet>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type FeedRow = {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly primary: string;
|
|
6
|
+
readonly trailing?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Nicht-tabellarische Kurzliste (z.B. "nächste Termine") — Primary-Text +
|
|
10
|
+
* optionaler rechtsbündiger Trailing-Wert pro Zeile. */
|
|
11
|
+
export function FeedList({
|
|
12
|
+
rows,
|
|
13
|
+
emptyContent,
|
|
14
|
+
testId,
|
|
15
|
+
}: {
|
|
16
|
+
readonly rows: readonly FeedRow[];
|
|
17
|
+
readonly emptyContent?: ReactNode;
|
|
18
|
+
readonly testId?: string;
|
|
19
|
+
}): ReactNode {
|
|
20
|
+
if (rows.length === 0) {
|
|
21
|
+
return <div data-testid={testId}>{emptyContent}</div>;
|
|
22
|
+
}
|
|
23
|
+
return (
|
|
24
|
+
<ul data-testid={testId} className="flex max-h-64 flex-col overflow-y-auto pr-1 text-sm">
|
|
25
|
+
{rows.map((row) => (
|
|
26
|
+
<li key={row.id} className="flex justify-between gap-4 border-b py-1.5 last:border-b-0">
|
|
27
|
+
<span>{row.primary}</span>
|
|
28
|
+
{row.trailing !== undefined && (
|
|
29
|
+
<span className="tabular-nums text-muted-foreground">{row.trailing}</span>
|
|
30
|
+
)}
|
|
31
|
+
</li>
|
|
32
|
+
))}
|
|
33
|
+
</ul>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
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
|
+
readonly testId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Zahlenfeld = Field + Input(kind:"number") in einem — nimmt der Screen die
|
|
16
|
+
* wiederholte id/name/value/onChange-Verdrahtung ab. `value` darf `undefined`
|
|
17
|
+
* sein (leeres Feld), intern auf den `""`-Empty-State des Inputs gemappt.
|
|
18
|
+
* Einheiten (€/%) gehören ins Label (`t("…Summe (€)")`), nicht als Badge. */
|
|
19
|
+
export function NumberField({
|
|
20
|
+
label,
|
|
21
|
+
id,
|
|
22
|
+
name,
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
required,
|
|
26
|
+
disabled,
|
|
27
|
+
testId,
|
|
28
|
+
}: NumberFieldProps): ReactNode {
|
|
29
|
+
const { Field, Input } = usePrimitives();
|
|
30
|
+
return (
|
|
31
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
32
|
+
<Input
|
|
33
|
+
kind="number"
|
|
34
|
+
id={id}
|
|
35
|
+
name={name}
|
|
36
|
+
value={value ?? ""}
|
|
37
|
+
onChange={onChange}
|
|
38
|
+
required={required}
|
|
39
|
+
disabled={disabled}
|
|
40
|
+
/>
|
|
41
|
+
</Field>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Aliase markieren die Feld-Absicht am Call-Site (lesbarer als NumberField
|
|
46
|
+
// überall) ohne eigene Wrapper-Funktion vorzutäuschen — beide sind identisch
|
|
47
|
+
// zu NumberField, kein Ort für künftige geld-/prozent-spezifische Formatierung.
|
|
48
|
+
export const MoneyField = NumberField;
|
|
49
|
+
export const PercentField = NumberField;
|
|
50
|
+
|
|
51
|
+
interface FieldBase {
|
|
52
|
+
readonly label: string;
|
|
53
|
+
readonly id: string;
|
|
54
|
+
readonly name: string;
|
|
55
|
+
readonly required?: boolean;
|
|
56
|
+
readonly disabled?: boolean;
|
|
57
|
+
readonly testId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TextFieldProps extends FieldBase {
|
|
61
|
+
readonly value: string;
|
|
62
|
+
readonly onChange: (v: string) => void;
|
|
63
|
+
readonly placeholder?: string;
|
|
64
|
+
readonly autoComplete?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Textfeld = Field + Input(kind:"text"). */
|
|
68
|
+
export function TextField({
|
|
69
|
+
label,
|
|
70
|
+
id,
|
|
71
|
+
name,
|
|
72
|
+
value,
|
|
73
|
+
onChange,
|
|
74
|
+
required,
|
|
75
|
+
disabled,
|
|
76
|
+
placeholder,
|
|
77
|
+
autoComplete,
|
|
78
|
+
testId,
|
|
79
|
+
}: TextFieldProps): ReactNode {
|
|
80
|
+
const { Field, Input } = usePrimitives();
|
|
81
|
+
return (
|
|
82
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
83
|
+
<Input
|
|
84
|
+
kind="text"
|
|
85
|
+
id={id}
|
|
86
|
+
name={name}
|
|
87
|
+
value={value}
|
|
88
|
+
onChange={onChange}
|
|
89
|
+
required={required}
|
|
90
|
+
disabled={disabled}
|
|
91
|
+
{...(placeholder !== undefined && { placeholder })}
|
|
92
|
+
{...(autoComplete !== undefined && { autoComplete })}
|
|
93
|
+
/>
|
|
94
|
+
</Field>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SelectFieldProps extends FieldBase {
|
|
99
|
+
readonly value: string;
|
|
100
|
+
readonly onChange: (v: string) => void;
|
|
101
|
+
readonly options:
|
|
102
|
+
| readonly string[]
|
|
103
|
+
| readonly { readonly value: string; readonly label: string }[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Auswahlfeld = Field + Input(kind:"select"). */
|
|
107
|
+
export function SelectField({
|
|
108
|
+
label,
|
|
109
|
+
id,
|
|
110
|
+
name,
|
|
111
|
+
value,
|
|
112
|
+
onChange,
|
|
113
|
+
options,
|
|
114
|
+
required,
|
|
115
|
+
disabled,
|
|
116
|
+
testId,
|
|
117
|
+
}: SelectFieldProps): ReactNode {
|
|
118
|
+
const { Field, Input } = usePrimitives();
|
|
119
|
+
return (
|
|
120
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
121
|
+
<Input
|
|
122
|
+
kind="select"
|
|
123
|
+
id={id}
|
|
124
|
+
name={name}
|
|
125
|
+
value={value}
|
|
126
|
+
onChange={onChange}
|
|
127
|
+
options={options}
|
|
128
|
+
required={required}
|
|
129
|
+
disabled={disabled}
|
|
130
|
+
/>
|
|
131
|
+
</Field>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface DateFieldProps extends FieldBase {
|
|
136
|
+
readonly value: string;
|
|
137
|
+
readonly onChange: (v: string) => void;
|
|
138
|
+
readonly min?: string;
|
|
139
|
+
readonly max?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Datumsfeld = Field + Input(kind:"date"), ISO yyyy-mm-dd. */
|
|
143
|
+
export function DateField({
|
|
144
|
+
label,
|
|
145
|
+
id,
|
|
146
|
+
name,
|
|
147
|
+
value,
|
|
148
|
+
onChange,
|
|
149
|
+
min,
|
|
150
|
+
max,
|
|
151
|
+
required,
|
|
152
|
+
disabled,
|
|
153
|
+
testId,
|
|
154
|
+
}: DateFieldProps): ReactNode {
|
|
155
|
+
const { Field, Input } = usePrimitives();
|
|
156
|
+
return (
|
|
157
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
158
|
+
<Input
|
|
159
|
+
kind="date"
|
|
160
|
+
id={id}
|
|
161
|
+
name={name}
|
|
162
|
+
value={value}
|
|
163
|
+
onChange={(v) => onChange(v ?? "")}
|
|
164
|
+
required={required}
|
|
165
|
+
disabled={disabled}
|
|
166
|
+
{...(min !== undefined && { min })}
|
|
167
|
+
{...(max !== undefined && { max })}
|
|
168
|
+
/>
|
|
169
|
+
</Field>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface BooleanFieldProps extends FieldBase {
|
|
174
|
+
readonly value: boolean;
|
|
175
|
+
readonly onChange: (v: boolean) => void;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Checkbox/Switch = Field(layout:"inline") + Input(kind:"boolean"). */
|
|
179
|
+
export function BooleanField({
|
|
180
|
+
label,
|
|
181
|
+
id,
|
|
182
|
+
name,
|
|
183
|
+
value,
|
|
184
|
+
onChange,
|
|
185
|
+
required,
|
|
186
|
+
disabled,
|
|
187
|
+
testId,
|
|
188
|
+
}: BooleanFieldProps): ReactNode {
|
|
189
|
+
const { Field, Input } = usePrimitives();
|
|
190
|
+
return (
|
|
191
|
+
<Field id={id} label={label} required={required} layout="inline" testId={testId}>
|
|
192
|
+
<Input
|
|
193
|
+
kind="boolean"
|
|
194
|
+
id={id}
|
|
195
|
+
name={name}
|
|
196
|
+
value={value}
|
|
197
|
+
onChange={onChange}
|
|
198
|
+
required={required}
|
|
199
|
+
disabled={disabled}
|
|
200
|
+
/>
|
|
201
|
+
</Field>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface TextareaFieldProps extends FieldBase {
|
|
206
|
+
readonly value: string;
|
|
207
|
+
readonly onChange: (v: string) => void;
|
|
208
|
+
readonly rows?: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Mehrzeiliges Textfeld = Field + Input(kind:"textarea"). */
|
|
212
|
+
export function TextareaField({
|
|
213
|
+
label,
|
|
214
|
+
id,
|
|
215
|
+
name,
|
|
216
|
+
value,
|
|
217
|
+
onChange,
|
|
218
|
+
rows,
|
|
219
|
+
required,
|
|
220
|
+
disabled,
|
|
221
|
+
testId,
|
|
222
|
+
}: TextareaFieldProps): ReactNode {
|
|
223
|
+
const { Field, Input } = usePrimitives();
|
|
224
|
+
return (
|
|
225
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
226
|
+
<Input
|
|
227
|
+
kind="textarea"
|
|
228
|
+
id={id}
|
|
229
|
+
name={name}
|
|
230
|
+
value={value}
|
|
231
|
+
onChange={onChange}
|
|
232
|
+
required={required}
|
|
233
|
+
disabled={disabled}
|
|
234
|
+
{...(rows !== undefined && { rows })}
|
|
235
|
+
/>
|
|
236
|
+
</Field>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface RangeFieldProps extends FieldBase {
|
|
241
|
+
readonly value: number;
|
|
242
|
+
readonly onChange: (v: number) => void;
|
|
243
|
+
readonly min: number;
|
|
244
|
+
readonly max: number;
|
|
245
|
+
readonly step?: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Schieberegler = Field + Input(kind:"range"). */
|
|
249
|
+
export function RangeField({
|
|
250
|
+
label,
|
|
251
|
+
id,
|
|
252
|
+
name,
|
|
253
|
+
value,
|
|
254
|
+
onChange,
|
|
255
|
+
min,
|
|
256
|
+
max,
|
|
257
|
+
step,
|
|
258
|
+
required,
|
|
259
|
+
disabled,
|
|
260
|
+
testId,
|
|
261
|
+
}: RangeFieldProps): ReactNode {
|
|
262
|
+
const { Field, Input } = usePrimitives();
|
|
263
|
+
return (
|
|
264
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
265
|
+
<Input
|
|
266
|
+
kind="range"
|
|
267
|
+
id={id}
|
|
268
|
+
name={name}
|
|
269
|
+
value={value}
|
|
270
|
+
onChange={onChange}
|
|
271
|
+
min={min}
|
|
272
|
+
max={max}
|
|
273
|
+
required={required}
|
|
274
|
+
disabled={disabled}
|
|
275
|
+
{...(step !== undefined && { step })}
|
|
276
|
+
/>
|
|
277
|
+
</Field>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface FileFieldProps extends FieldBase {
|
|
282
|
+
/** FileRef-UUID der gespeicherten Datei, oder null. */
|
|
283
|
+
readonly value: string | null;
|
|
284
|
+
readonly onChange: (fileId: string | null) => void;
|
|
285
|
+
readonly accept?: readonly string[];
|
|
286
|
+
/** "image" zeigt eine Vorschau, "file" nur den Dateinamen. Default "file". */
|
|
287
|
+
readonly variant?: "file" | "image";
|
|
288
|
+
/** Bindet den Upload an ein konkretes Entity-Feld — der `/api/files`-Endpoint
|
|
289
|
+
* prüft `accept`/`maxSize` dann serverseitig gegen dessen Feld-Definition. */
|
|
290
|
+
readonly entityType?: string;
|
|
291
|
+
readonly fieldName?: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Datei-Upload = Field + Input(kind:"file"|"image") — FileRef-basiert. */
|
|
295
|
+
export function FileField({
|
|
296
|
+
label,
|
|
297
|
+
id,
|
|
298
|
+
name,
|
|
299
|
+
value,
|
|
300
|
+
onChange,
|
|
301
|
+
accept,
|
|
302
|
+
variant = "file",
|
|
303
|
+
entityType,
|
|
304
|
+
fieldName,
|
|
305
|
+
required,
|
|
306
|
+
disabled,
|
|
307
|
+
testId,
|
|
308
|
+
}: FileFieldProps): ReactNode {
|
|
309
|
+
const { Field, Input } = usePrimitives();
|
|
310
|
+
return (
|
|
311
|
+
<Field id={id} label={label} required={required} testId={testId}>
|
|
312
|
+
<Input
|
|
313
|
+
kind={variant}
|
|
314
|
+
id={id}
|
|
315
|
+
name={name}
|
|
316
|
+
value={value}
|
|
317
|
+
onChange={onChange}
|
|
318
|
+
required={required}
|
|
319
|
+
disabled={disabled}
|
|
320
|
+
{...(accept !== undefined && { accept })}
|
|
321
|
+
{...(entityType !== undefined && { entityType })}
|
|
322
|
+
{...(fieldName !== undefined && { fieldName })}
|
|
323
|
+
/>
|
|
324
|
+
</Field>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
AiTextArea,
|
|
8
|
+
type AiTextAreaProps,
|
|
9
|
+
AiTextField,
|
|
10
|
+
type AiTextFieldProps,
|
|
11
|
+
} from "./ai-text-field";
|
|
12
|
+
export {
|
|
13
|
+
StatusBarChart,
|
|
14
|
+
type StatusBarEntry,
|
|
15
|
+
smoothPath,
|
|
16
|
+
TimeseriesChart,
|
|
17
|
+
type TimeseriesPoint,
|
|
18
|
+
} from "./charts";
|
|
19
|
+
export { CollapsibleSection } from "./collapsible-section";
|
|
20
|
+
export { DetailList } from "./detail-list";
|
|
21
|
+
export { Drawer, type DrawerProps } from "./drawer";
|
|
22
|
+
export { FeedList, type FeedRow } from "./feed-list";
|
|
23
|
+
export {
|
|
24
|
+
BooleanField,
|
|
25
|
+
type BooleanFieldProps,
|
|
26
|
+
DateField,
|
|
27
|
+
type DateFieldProps,
|
|
28
|
+
FileField,
|
|
29
|
+
type FileFieldProps,
|
|
30
|
+
MoneyField,
|
|
31
|
+
NumberField,
|
|
32
|
+
type NumberFieldProps,
|
|
33
|
+
PercentField,
|
|
34
|
+
RangeField,
|
|
35
|
+
type RangeFieldProps,
|
|
36
|
+
SelectField,
|
|
37
|
+
type SelectFieldProps,
|
|
38
|
+
TextareaField,
|
|
39
|
+
type TextareaFieldProps,
|
|
40
|
+
TextField,
|
|
41
|
+
type TextFieldProps,
|
|
42
|
+
} from "./form-fields";
|
|
43
|
+
export { InfinityList, type InfinityListProps } from "./infinity-list";
|
|
44
|
+
export { ModeSwitch } from "./mode-switch";
|
|
45
|
+
export { ProgressBar } from "./progress-bar";
|
|
46
|
+
export { ProgressList, type ProgressListRow } from "./progress-list";
|
|
47
|
+
export { QueryTable, type QueryTableColumn, type QueryTableProps } from "./query-table";
|
|
48
|
+
export {
|
|
49
|
+
type ComparisonMetric,
|
|
50
|
+
ComparisonTable,
|
|
51
|
+
type ResultColumn,
|
|
52
|
+
ResultPanel,
|
|
53
|
+
ResultTable,
|
|
54
|
+
} from "./result-panel";
|
|
55
|
+
export { SectionCard } from "./section-card";
|
|
56
|
+
export { MiniStat, Sparkline, StatCard, type StatDelta, type StatTone } from "./stat";
|
|
57
|
+
export { EmptyState, ErrorState, LoadingState } from "./states";
|
|
58
|
+
export { STATUS_TONE_TEXT, StatusBadge, type StatusTone } from "./status-badge";
|
|
59
|
+
export { useDraft } from "./use-draft";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { EmptyState, ErrorState, LoadingState } from "./states";
|
|
5
|
+
|
|
6
|
+
export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, unknown>>> = {
|
|
7
|
+
/** Dispatcher-Query-Type (`<feature>:query:<entity>:<verb>`). */
|
|
8
|
+
readonly query: string;
|
|
9
|
+
readonly payload?: Readonly<Record<string, unknown>>;
|
|
10
|
+
/** Rows per page — sent as `limit` in the query payload. Default 50. */
|
|
11
|
+
readonly pageSize?: number;
|
|
12
|
+
readonly rows: (data: TData) => readonly TRow[];
|
|
13
|
+
/** Pull the next-page cursor from the result; `null` means last page. */
|
|
14
|
+
readonly nextCursor: (data: TData) => string | null;
|
|
15
|
+
readonly rowId: (row: TRow, index: number) => string;
|
|
16
|
+
readonly renderRow: (row: TRow) => ReactNode;
|
|
17
|
+
readonly emptyState?: ReactNode;
|
|
18
|
+
readonly className?: string;
|
|
19
|
+
readonly testId?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type State<TRow> =
|
|
23
|
+
| { readonly kind: "loading" }
|
|
24
|
+
| { readonly kind: "error"; readonly error: DispatcherError }
|
|
25
|
+
| { readonly kind: "ready"; readonly rows: readonly TRow[]; readonly cursor: string | null };
|
|
26
|
+
|
|
27
|
+
/** Cursor-paginated scroll list (mail inbox, activity feeds) — loads the
|
|
28
|
+
* next page when the end sentinel becomes visible (IntersectionObserver),
|
|
29
|
+
* instead of a pager bar like QueryTable/entityList. The query handler
|
|
30
|
+
* receives `{ ...payload, limit, cursor? }` and returns rows plus the next
|
|
31
|
+
* cursor (same cursor convention as the audit-log screen). */
|
|
32
|
+
export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unknown>>>({
|
|
33
|
+
query,
|
|
34
|
+
payload,
|
|
35
|
+
pageSize = 50,
|
|
36
|
+
rows,
|
|
37
|
+
nextCursor,
|
|
38
|
+
rowId,
|
|
39
|
+
renderRow,
|
|
40
|
+
emptyState,
|
|
41
|
+
className,
|
|
42
|
+
testId,
|
|
43
|
+
}: InfinityListProps<TData, TRow>): ReactNode {
|
|
44
|
+
const dispatcher = useDispatcher();
|
|
45
|
+
const t = useTranslation();
|
|
46
|
+
const [state, setState] = useState<State<TRow>>({ kind: "loading" });
|
|
47
|
+
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
48
|
+
const payloadKey = JSON.stringify(payload ?? {});
|
|
49
|
+
|
|
50
|
+
// rows/nextCursor are fresh closures on every caller render (inline
|
|
51
|
+
// arrow props). As useCallback deps that would recreate `load` every
|
|
52
|
+
// render → the mount effect below would refetch in a loop. Refs keep
|
|
53
|
+
// `load` stable while always reading the current selector.
|
|
54
|
+
const rowsRef = useRef(rows);
|
|
55
|
+
rowsRef.current = rows;
|
|
56
|
+
const nextCursorRef = useRef(nextCursor);
|
|
57
|
+
nextCursorRef.current = nextCursor;
|
|
58
|
+
|
|
59
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
|
|
60
|
+
const load = useCallback(
|
|
61
|
+
async (cursor: string | null): Promise<void> => {
|
|
62
|
+
const res = await dispatcher.query<TData>(query, {
|
|
63
|
+
...payload,
|
|
64
|
+
limit: pageSize,
|
|
65
|
+
...(cursor !== null && { cursor }),
|
|
66
|
+
});
|
|
67
|
+
if (!res.isSuccess) {
|
|
68
|
+
setState({ kind: "error", error: res.error });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const nextRows = rowsRef.current(res.data);
|
|
72
|
+
setState((prev) => ({
|
|
73
|
+
kind: "ready",
|
|
74
|
+
rows: cursor === null || prev.kind !== "ready" ? nextRows : [...prev.rows, ...nextRows],
|
|
75
|
+
cursor: nextCursorRef.current(res.data),
|
|
76
|
+
}));
|
|
77
|
+
},
|
|
78
|
+
[dispatcher, query, pageSize, payloadKey],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setState({ kind: "loading" });
|
|
83
|
+
void load(null);
|
|
84
|
+
}, [load]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const sentinel = sentinelRef.current;
|
|
88
|
+
// skip: no further page or not ready yet — observer not needed
|
|
89
|
+
if (sentinel === null || state.kind !== "ready" || state.cursor === null) return;
|
|
90
|
+
const cursor = state.cursor;
|
|
91
|
+
const observer = new IntersectionObserver((entries) => {
|
|
92
|
+
if (entries[0]?.isIntersecting === true) void load(cursor);
|
|
93
|
+
});
|
|
94
|
+
observer.observe(sentinel);
|
|
95
|
+
return () => observer.disconnect();
|
|
96
|
+
}, [state, load]);
|
|
97
|
+
|
|
98
|
+
if (state.kind === "loading") return <LoadingState rows={4} testId={testId} />;
|
|
99
|
+
if (state.kind === "error")
|
|
100
|
+
return <ErrorState error={state.error} onRetry={() => void load(null)} testId={testId} />;
|
|
101
|
+
if (state.rows.length === 0)
|
|
102
|
+
return <>{emptyState ?? <EmptyState title={t("kumiko.list.no-entries")} testId={testId} />}</>;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div data-testid={testId} className={className ?? "flex flex-col overflow-y-auto"}>
|
|
106
|
+
{state.rows.map((row, index) => (
|
|
107
|
+
<div key={rowId(row, index)}>{renderRow(row)}</div>
|
|
108
|
+
))}
|
|
109
|
+
<div ref={sentinelRef} className="h-px" />
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/** Segmented-Control für sich ausschließende Modi — die prominente
|
|
5
|
+
* Alternative zum vergrabenen <select>. */
|
|
6
|
+
export function ModeSwitch<T extends string>({
|
|
7
|
+
value,
|
|
8
|
+
options,
|
|
9
|
+
onChange,
|
|
10
|
+
testId,
|
|
11
|
+
}: {
|
|
12
|
+
readonly value: T;
|
|
13
|
+
readonly options: readonly { readonly value: T; readonly label: string }[];
|
|
14
|
+
readonly onChange: (value: T) => void;
|
|
15
|
+
readonly testId?: string;
|
|
16
|
+
}): ReactNode {
|
|
17
|
+
return (
|
|
18
|
+
// biome-ignore lint/a11y/useSemanticElements: fieldset bringt Browser-Default-Chrome (Border/legend) mit, das für ein Button-Segmented-Control falsch ist
|
|
19
|
+
<div data-testid={testId} role="group" className="flex flex-wrap gap-1">
|
|
20
|
+
{options.map((o) => {
|
|
21
|
+
const active = o.value === value;
|
|
22
|
+
return (
|
|
23
|
+
<button
|
|
24
|
+
key={o.value}
|
|
25
|
+
type="button"
|
|
26
|
+
aria-pressed={active}
|
|
27
|
+
onClick={() => onChange(o.value)}
|
|
28
|
+
className={cn(
|
|
29
|
+
"rounded-md border px-3 py-1.5 text-sm transition-colors",
|
|
30
|
+
active
|
|
31
|
+
? "border-transparent bg-secondary font-semibold text-secondary-foreground"
|
|
32
|
+
: "text-muted-foreground hover:bg-muted",
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
{o.label}
|
|
36
|
+
</button>
|
|
37
|
+
);
|
|
38
|
+
})}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/** Fortschrittsbalken, `value` 0..1 (wird geclampt). */
|
|
5
|
+
export function ProgressBar({
|
|
6
|
+
value,
|
|
7
|
+
className,
|
|
8
|
+
testId,
|
|
9
|
+
}: {
|
|
10
|
+
readonly value: number;
|
|
11
|
+
readonly className?: string;
|
|
12
|
+
readonly testId?: string;
|
|
13
|
+
}): ReactNode {
|
|
14
|
+
const pct = Math.max(0, Math.min(1, value));
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
data-testid={testId}
|
|
18
|
+
role="progressbar"
|
|
19
|
+
aria-valuenow={Math.round(pct * 100)}
|
|
20
|
+
aria-valuemin={0}
|
|
21
|
+
aria-valuemax={100}
|
|
22
|
+
className={cn("h-2 w-full overflow-hidden rounded-full bg-muted", className)}
|
|
23
|
+
>
|
|
24
|
+
<div className="h-full rounded-full bg-primary" style={{ width: `${pct * 100}%` }} />
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { ProgressBar } from "./progress-bar";
|
|
3
|
+
|
|
4
|
+
export type ProgressListRow = {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly value: string;
|
|
8
|
+
readonly fraction: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Liste aus Label/Wert-Kopfzeile + Fortschrittsbalken pro Eintrag (z.B.
|
|
12
|
+
* Tilgungsfortschritt pro Kredit). */
|
|
13
|
+
export function ProgressList({
|
|
14
|
+
rows,
|
|
15
|
+
emptyContent,
|
|
16
|
+
testId,
|
|
17
|
+
}: {
|
|
18
|
+
readonly rows: readonly ProgressListRow[];
|
|
19
|
+
readonly emptyContent?: ReactNode;
|
|
20
|
+
readonly testId?: string;
|
|
21
|
+
}): ReactNode {
|
|
22
|
+
if (rows.length === 0) {
|
|
23
|
+
return <div data-testid={testId}>{emptyContent}</div>;
|
|
24
|
+
}
|
|
25
|
+
return (
|
|
26
|
+
<ul data-testid={testId} className="flex max-h-64 flex-col gap-3 overflow-y-auto pr-1">
|
|
27
|
+
{rows.map((row) => (
|
|
28
|
+
<li key={row.id} className="flex flex-col gap-1">
|
|
29
|
+
<div className="flex items-baseline justify-between gap-2 text-sm">
|
|
30
|
+
<span className="font-medium">{row.label}</span>
|
|
31
|
+
<span className="tabular-nums text-muted-foreground">{row.value}</span>
|
|
32
|
+
</div>
|
|
33
|
+
<ProgressBar value={row.fraction} />
|
|
34
|
+
</li>
|
|
35
|
+
))}
|
|
36
|
+
</ul>
|
|
37
|
+
);
|
|
38
|
+
}
|