@cosmicdrift/kumiko-renderer-web 0.146.4 → 0.147.1

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.
@@ -33,6 +33,19 @@ describe("useDraft", () => {
33
33
  act(() => result.current.reset());
34
34
  expect(result.current.draft.a).toBe(1);
35
35
  });
36
+
37
+ test("idPrefix verhindert doppelte DOM-ids bei mehreren useDraft-Formularen mit gleichem Feldnamen", () => {
38
+ const a = renderHook(() =>
39
+ useDraft<{ sum: number | undefined }>({ sum: 1 }, { idPrefix: "a" }),
40
+ );
41
+ const b = renderHook(() =>
42
+ useDraft<{ sum: number | undefined }>({ sum: 2 }, { idPrefix: "b" }),
43
+ );
44
+ expect(a.result.current.field("sum").id).toBe("a-sum");
45
+ expect(b.result.current.field("sum").id).toBe("b-sum");
46
+ // name bleibt der reine Key — nur die DOM-id wird prefixt.
47
+ expect(a.result.current.field("sum").name).toBe("sum");
48
+ });
36
49
  });
37
50
 
38
51
  describe("NumberField", () => {
@@ -217,6 +230,70 @@ describe("FileField", () => {
217
230
  );
218
231
  expect(screen.getByText("Bild")).toBeTruthy();
219
232
  });
233
+
234
+ test('variant="image" mit gesetzter FileRef zeigt Preview + Remove-Button', () => {
235
+ render(
236
+ <FileField
237
+ label="Bild"
238
+ id="f3"
239
+ name="f3"
240
+ value="file-abc"
241
+ onChange={() => {}}
242
+ variant="image"
243
+ />,
244
+ );
245
+ const img = document.querySelector("img") as HTMLImageElement;
246
+ expect(img.src).toContain("/api/files/file-abc");
247
+ expect(screen.getByRole("button", { name: "Remove" })).toBeTruthy();
248
+ });
249
+
250
+ test("Remove-Button ruft onChange(null)", () => {
251
+ const onChange = mock();
252
+ render(
253
+ <FileField
254
+ label="Bild"
255
+ id="f4"
256
+ name="f4"
257
+ value="file-abc"
258
+ onChange={onChange}
259
+ variant="image"
260
+ />,
261
+ );
262
+ fireEvent.click(screen.getByRole("button", { name: "Remove" }));
263
+ expect(onChange).toHaveBeenCalledWith(null);
264
+ });
265
+
266
+ test("entityType/fieldName reichen bis in den /api/files-Upload-Request durch", async () => {
267
+ const originalFetch = global.fetch;
268
+ const fetchSpy = mock(
269
+ async (_url: string, _init?: RequestInit) => new Response(JSON.stringify({ id: "file-1" })),
270
+ );
271
+ global.fetch = fetchSpy as unknown as typeof fetch;
272
+ try {
273
+ render(
274
+ <FileField
275
+ label="Anhang"
276
+ id="f2"
277
+ name="f2"
278
+ value={null}
279
+ onChange={() => {}}
280
+ entityType="property"
281
+ fieldName="brochure"
282
+ />,
283
+ );
284
+ const input = document.getElementById("f2") as HTMLInputElement;
285
+ const file = new File(["x"], "brochure.pdf", { type: "application/pdf" });
286
+ await act(async () => {
287
+ fireEvent.change(input, { target: { files: [file] } });
288
+ });
289
+ expect(fetchSpy).toHaveBeenCalled();
290
+ const body = fetchSpy.mock.calls[0]?.[1]?.body as FormData;
291
+ expect(body.get("entityType")).toBe("property");
292
+ expect(body.get("fieldName")).toBe("brochure");
293
+ } finally {
294
+ global.fetch = originalFetch;
295
+ }
296
+ });
220
297
  });
221
298
 
222
299
  describe("Button size", () => {
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render, screen } from "../../__tests__/test-utils";
3
+ import { ComparisonTable, ResultTable } from "../result-panel";
4
+
5
+ const COLUMNS = [
6
+ { header: "Jahr", cell: (row: { year: number; total: number }) => row.year },
7
+ {
8
+ header: "Summe",
9
+ align: "right" as const,
10
+ cell: (row: { year: number; total: number }) => row.total,
11
+ },
12
+ ];
13
+ const ROWS = [
14
+ { year: 2026, total: 100 },
15
+ { year: 2027, total: 200 },
16
+ ];
17
+
18
+ describe("ResultTable", () => {
19
+ test("card=true wrapt in den gerundeten Border-Container mit bg-muted-Header", () => {
20
+ const { container } = render(
21
+ <ResultTable columns={COLUMNS} rows={ROWS} rowKey={(r) => String(r.year)} card testId="rt" />,
22
+ );
23
+ expect(container.querySelector(".rounded-lg.border.bg-card")).toBeTruthy();
24
+ expect(container.querySelector("thead.bg-muted")).toBeTruthy();
25
+ expect(screen.getByText("2026")).toBeTruthy();
26
+ });
27
+
28
+ test("ohne card kein Wrapper (bare default)", () => {
29
+ const { container } = render(
30
+ <ResultTable columns={COLUMNS} rows={ROWS} rowKey={(r) => String(r.year)} testId="rt" />,
31
+ );
32
+ expect(container.querySelector(".rounded-lg.border.bg-card")).toBeNull();
33
+ expect(container.querySelector("thead.bg-muted")).toBeNull();
34
+ });
35
+ });
36
+
37
+ describe("ComparisonTable", () => {
38
+ const cols = ["A", "B"];
39
+ const metrics = [{ label: "Zins", value: (col: string) => `${col}-Zins` }];
40
+
41
+ test("card=true wrapt in den gerundeten Border-Container mit bg-muted-Header", () => {
42
+ const { container } = render(
43
+ <ComparisonTable
44
+ columns={cols}
45
+ columnHeader={(c) => c}
46
+ columnKey={(c) => c}
47
+ metrics={metrics}
48
+ metricLabel="Kennzahl"
49
+ card
50
+ testId="ct"
51
+ />,
52
+ );
53
+ expect(container.querySelector(".rounded-lg.border.bg-card")).toBeTruthy();
54
+ expect(container.querySelector("thead.bg-muted")).toBeTruthy();
55
+ expect(screen.getByText("A-Zins")).toBeTruthy();
56
+ });
57
+
58
+ test("ohne card kein Wrapper (bare default)", () => {
59
+ const { container } = render(
60
+ <ComparisonTable
61
+ columns={cols}
62
+ columnHeader={(c) => c}
63
+ columnKey={(c) => c}
64
+ metrics={metrics}
65
+ metricLabel="Kennzahl"
66
+ testId="ct"
67
+ />,
68
+ );
69
+ expect(container.querySelector(".rounded-lg.border.bg-card")).toBeNull();
70
+ expect(container.querySelector("thead.bg-muted")).toBeNull();
71
+ });
72
+ });
@@ -62,6 +62,20 @@ describe("ModeSwitch", () => {
62
62
  fireEvent.click(screen.getByRole("button", { name: "Modus B" }));
63
63
  expect(onChange).toHaveBeenCalledWith("b");
64
64
  });
65
+
66
+ test("#902: Buttons sind als zusammengehörige Gruppe gekennzeichnet", () => {
67
+ render(
68
+ <ModeSwitch
69
+ value="a"
70
+ options={[
71
+ { value: "a", label: "Modus A" },
72
+ { value: "b", label: "Modus B" },
73
+ ]}
74
+ onChange={() => {}}
75
+ />,
76
+ );
77
+ expect(screen.getByRole("group")).toBeTruthy();
78
+ });
65
79
  });
66
80
 
67
81
  describe("CollapsibleSection", () => {
@@ -0,0 +1,370 @@
1
+ // AiTextField / AiTextArea — Drop-in replacement for TextField/TextareaField
2
+ // with AI-augmented actions (ai-text-primitive plan doc, Phase 3).
3
+ //
4
+ // Built on raw <input>/<textarea> (not the generic Input primitive) because
5
+ // ghost-text needs a keydown handler (Tab/Esc) and an overlay that the
6
+ // discriminated-union Input contract doesn't expose. Same pattern as
7
+ // MoneyInput — a hand-built control wrapped in Field.
8
+ //
9
+ // Graceful degradation: `useAiTextAction`/`useCompletion` surface
10
+ // "unavailable" when the server feature isn't mounted (`feature_disabled`)
11
+ // — the toolbar hides and the field behaves like a plain text field, no
12
+ // error shown to the end-user.
13
+ //
14
+ // Ghost-text simplification: the overlay only ever appends after the
15
+ // current value (no mid-text ghost) — matches the server prompt design
16
+ // (ai-text's `complete` mode only ever continues forward from the given
17
+ // text). Two-layer overlay trick: an aria-hidden div behind the real
18
+ // input renders `value` (invisible, same font/box so it reserves space)
19
+ // followed by the suggestion in muted color; the real input sits on top
20
+ // with a transparent background so the suggestion shows through past the
21
+ // typed text. Both elements MUST share identical typography/box classes
22
+ // or the suggestion won't align with the caret.
23
+
24
+ import type { AiTextRewriteStyle } from "@cosmicdrift/kumiko-renderer";
25
+ import {
26
+ useAiTextAction,
27
+ useCompletion,
28
+ usePrimitives,
29
+ useTranslation,
30
+ } from "@cosmicdrift/kumiko-renderer";
31
+ import { Check, Languages, Wand2 } from "lucide-react";
32
+ import { type KeyboardEvent, type ReactNode, useLayoutEffect, useRef, useState } from "react";
33
+ import { cn } from "../lib/cn";
34
+ import { ModeSwitch } from "./mode-switch";
35
+
36
+ type AiTextAction = "correct" | "translate" | "rewrite";
37
+
38
+ interface AiTextFieldBaseProps {
39
+ readonly label: string;
40
+ readonly id: string;
41
+ readonly name: string;
42
+ readonly value: string;
43
+ readonly onChange: (v: string) => void;
44
+ readonly required?: boolean;
45
+ readonly disabled?: boolean;
46
+ readonly placeholder?: string;
47
+ readonly testId?: string;
48
+ /** Ghost-text completion on/off. Default true. */
49
+ readonly completion?: boolean;
50
+ /** Debounce for ghost-text requests, ms. Default 500 — kept low in
51
+ * tests, tuned for real typing in production. */
52
+ readonly completionDebounceMs?: number;
53
+ /** Toolbar actions to expose. Default all three. */
54
+ readonly actions?: readonly AiTextAction[];
55
+ /** Target languages the translate action offers. First is preselected.
56
+ * Default `["en", "de", "fr", "es"]`. */
57
+ readonly translateLanguages?: readonly string[];
58
+ }
59
+
60
+ export interface AiTextFieldProps extends AiTextFieldBaseProps {}
61
+ export interface AiTextAreaProps extends AiTextFieldBaseProps {
62
+ readonly rows?: number;
63
+ }
64
+
65
+ const DEFAULT_LANGUAGES: readonly string[] = ["en", "de", "fr", "es"];
66
+ const DEFAULT_ACTIONS: readonly AiTextAction[] = ["correct", "translate", "rewrite"];
67
+ const REWRITE_STYLES: readonly AiTextRewriteStyle[] = ["formal", "casual", "concise", "expand"];
68
+
69
+ const sharedTextClass =
70
+ "w-full rounded-md border border-input px-3 py-2 text-sm shadow-sm leading-6 " +
71
+ "placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 " +
72
+ "focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
73
+
74
+ // Overlay and real control must share identical wrap/overflow behavior or the
75
+ // ghost suggestion drifts from the caret: <input> never wraps and scrolls
76
+ // horizontally, <textarea> soft-wraps and scrolls vertically.
77
+ const singleLineWrapClass = "whitespace-pre overflow-hidden";
78
+ const multilineWrapClass = "whitespace-pre-wrap break-words overflow-hidden";
79
+
80
+ function AiTextCore({
81
+ multiline,
82
+ label,
83
+ id,
84
+ name,
85
+ value,
86
+ onChange,
87
+ required,
88
+ disabled,
89
+ placeholder,
90
+ testId,
91
+ rows,
92
+ completion = true,
93
+ completionDebounceMs = 500,
94
+ actions = DEFAULT_ACTIONS,
95
+ translateLanguages = DEFAULT_LANGUAGES,
96
+ }: AiTextFieldBaseProps & { readonly multiline: boolean; readonly rows?: number }): ReactNode {
97
+ const { Field, Button, Dialog, Text } = usePrimitives();
98
+ const t = useTranslation();
99
+
100
+ const {
101
+ suggestion,
102
+ state: completionState,
103
+ requestCompletion,
104
+ clear: clearCompletion,
105
+ } = useCompletion(completionDebounceMs);
106
+ const {
107
+ run: runAction,
108
+ state: actionState,
109
+ result: actionResult,
110
+ reset: resetAction,
111
+ } = useAiTextAction();
112
+
113
+ const [dialogMode, setDialogMode] = useState<AiTextAction | null>(null);
114
+ const [targetLanguage, setTargetLanguage] = useState(translateLanguages[0] ?? "en");
115
+ const [rewriteStyle, setRewriteStyle] = useState<AiTextRewriteStyle>("concise");
116
+ const overlayRef = useRef<HTMLDivElement>(null);
117
+ const controlRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
118
+
119
+ const unavailable = completionState === "unavailable" || actionState === "unavailable";
120
+ const capExceeded = completionState === "cap-exceeded" || actionState === "cap-exceeded";
121
+ const showToolbar = !unavailable && actions.length > 0;
122
+ const showGhost = completion && !unavailable && suggestion !== null && suggestion.length > 0;
123
+
124
+ // ponytail: a manually `resize-y`-shrunk textarea can end up a few px
125
+ // shorter than the overlay div (which has no resize handle), so the
126
+ // synced scrollTop clamps slightly early — sub-line drift, invisible at
127
+ // normal line-height. Upgrade if it ever becomes visually noticeable.
128
+ // biome-ignore lint/correctness/useExhaustiveDependencies: value/suggestion are trigger-only, effect reads refs not props
129
+ useLayoutEffect(() => {
130
+ if (overlayRef.current === null || controlRef.current === null) return;
131
+ overlayRef.current.scrollLeft = controlRef.current.scrollLeft;
132
+ overlayRef.current.scrollTop = controlRef.current.scrollTop;
133
+ }, [value, suggestion]);
134
+
135
+ function handleChange(next: string): void {
136
+ onChange(next);
137
+ if (completion && !unavailable) {
138
+ requestCompletion(next);
139
+ }
140
+ }
141
+
142
+ function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>): void {
143
+ if (suggestion === null) return;
144
+ if (e.key === "Tab" && e.currentTarget.selectionStart === value.length) {
145
+ e.preventDefault();
146
+ onChange(value + suggestion);
147
+ clearCompletion();
148
+ } else if (e.key === "Escape") {
149
+ e.preventDefault();
150
+ clearCompletion();
151
+ }
152
+ }
153
+
154
+ function openDialog(mode: AiTextAction): void {
155
+ resetAction();
156
+ setDialogMode(mode);
157
+ if (mode === "correct") {
158
+ void runAction({ mode: "correct", text: value });
159
+ }
160
+ }
161
+
162
+ function closeDialog(): void {
163
+ setDialogMode(null);
164
+ resetAction();
165
+ }
166
+
167
+ function runConfiguredAction(): void {
168
+ if (dialogMode === "translate") {
169
+ void runAction({ mode: "translate", text: value, targetLanguage });
170
+ } else if (dialogMode === "rewrite") {
171
+ void runAction({ mode: "rewrite", text: value, style: rewriteStyle });
172
+ }
173
+ }
174
+
175
+ function applyResult(): void {
176
+ if (actionResult?.type === "text") onChange(actionResult.text);
177
+ }
178
+
179
+ const needsConfig =
180
+ (dialogMode === "translate" || dialogMode === "rewrite") &&
181
+ actionState !== "loading" &&
182
+ actionState !== "success";
183
+
184
+ return (
185
+ <>
186
+ <Field id={id} label={label} required={required} testId={testId}>
187
+ <div className="relative w-full">
188
+ {showGhost && (
189
+ <div
190
+ ref={overlayRef}
191
+ aria-hidden="true"
192
+ className={cn(
193
+ sharedTextClass,
194
+ multiline ? multilineWrapClass : singleLineWrapClass,
195
+ "pointer-events-none absolute inset-0 select-none border-transparent shadow-none",
196
+ )}
197
+ >
198
+ <span className="invisible">{value}</span>
199
+ <span className="text-muted-foreground">{suggestion}</span>
200
+ </div>
201
+ )}
202
+ {multiline ? (
203
+ <textarea
204
+ ref={(el) => {
205
+ controlRef.current = el;
206
+ }}
207
+ id={id}
208
+ name={name}
209
+ value={value}
210
+ required={required}
211
+ disabled={disabled === true || unavailable}
212
+ placeholder={placeholder}
213
+ rows={rows ?? 4}
214
+ onChange={(e) => handleChange(e.target.value)}
215
+ onKeyDown={handleKeyDown}
216
+ onBlur={clearCompletion}
217
+ onScroll={(e) => {
218
+ if (overlayRef.current) overlayRef.current.scrollTop = e.currentTarget.scrollTop;
219
+ }}
220
+ className={cn(
221
+ sharedTextClass,
222
+ multilineWrapClass,
223
+ "relative resize-y bg-transparent",
224
+ )}
225
+ data-testid={testId !== undefined ? `${testId}-input` : undefined}
226
+ />
227
+ ) : (
228
+ <input
229
+ ref={(el) => {
230
+ controlRef.current = el;
231
+ }}
232
+ id={id}
233
+ name={name}
234
+ type="text"
235
+ value={value}
236
+ required={required}
237
+ disabled={disabled === true || unavailable}
238
+ placeholder={placeholder}
239
+ onChange={(e) => handleChange(e.target.value)}
240
+ onKeyDown={handleKeyDown}
241
+ onBlur={clearCompletion}
242
+ onScroll={(e) => {
243
+ if (overlayRef.current) overlayRef.current.scrollLeft = e.currentTarget.scrollLeft;
244
+ }}
245
+ className={cn(sharedTextClass, singleLineWrapClass, "relative bg-transparent")}
246
+ data-testid={testId !== undefined ? `${testId}-input` : undefined}
247
+ />
248
+ )}
249
+ </div>
250
+
251
+ {showGhost && <Text variant="muted">{t("kumiko.aiText.acceptHint")}</Text>}
252
+ {capExceeded && <Text variant="muted">{t("kumiko.aiText.capExceeded")}</Text>}
253
+
254
+ {showToolbar && (
255
+ <div className="mt-1 flex gap-1">
256
+ {actions.includes("correct") && (
257
+ <Button
258
+ variant="secondary"
259
+ size="sm"
260
+ disabled={value.length === 0}
261
+ ariaLabel={t("kumiko.aiText.correct")}
262
+ onClick={() => openDialog("correct")}
263
+ >
264
+ <Check className="size-3.5" aria-hidden="true" />
265
+ </Button>
266
+ )}
267
+ {actions.includes("translate") && (
268
+ <Button
269
+ variant="secondary"
270
+ size="sm"
271
+ disabled={value.length === 0}
272
+ ariaLabel={t("kumiko.aiText.translate")}
273
+ onClick={() => openDialog("translate")}
274
+ >
275
+ <Languages className="size-3.5" aria-hidden="true" />
276
+ </Button>
277
+ )}
278
+ {actions.includes("rewrite") && (
279
+ <Button
280
+ variant="secondary"
281
+ size="sm"
282
+ disabled={value.length === 0}
283
+ ariaLabel={t("kumiko.aiText.rewrite")}
284
+ onClick={() => openDialog("rewrite")}
285
+ >
286
+ <Wand2 className="size-3.5" aria-hidden="true" />
287
+ </Button>
288
+ )}
289
+ </div>
290
+ )}
291
+ </Field>
292
+
293
+ {/* ponytail: Dialog's built-in Confirm button is always live, even
294
+ while needsConfig is still showing the language/style picker —
295
+ applyResult() no-ops until actionResult exists, so an early click
296
+ just closes the dialog with nothing applied. Gating it needs a
297
+ confirmDisabled prop on the shared Dialog primitive (other
298
+ callers too); not worth widening that contract for one caller.
299
+ Upgrade if this trips users up in practice. */}
300
+ <Dialog
301
+ open={dialogMode !== null}
302
+ onOpenChange={(open) => {
303
+ if (!open) closeDialog();
304
+ }}
305
+ title={dialogMode !== null ? t(`kumiko.aiText.${dialogMode}`) : ""}
306
+ onConfirm={applyResult}
307
+ testId={testId !== undefined ? `${testId}-dialog` : undefined}
308
+ >
309
+ {dialogMode === "translate" && needsConfig && (
310
+ <div className="mb-3 flex flex-col gap-2">
311
+ <ModeSwitch
312
+ value={targetLanguage}
313
+ options={translateLanguages.map((l) => ({ value: l, label: l.toUpperCase() }))}
314
+ onChange={setTargetLanguage}
315
+ />
316
+ <Button variant="primary" size="sm" onClick={runConfiguredAction}>
317
+ {t(`kumiko.aiText.${dialogMode}`)}
318
+ </Button>
319
+ </div>
320
+ )}
321
+ {dialogMode === "rewrite" && needsConfig && (
322
+ <div className="mb-3 flex flex-col gap-2">
323
+ <ModeSwitch
324
+ value={rewriteStyle}
325
+ options={REWRITE_STYLES.map((s) => ({
326
+ value: s,
327
+ label: t(`kumiko.aiText.style.${s}`),
328
+ }))}
329
+ onChange={setRewriteStyle}
330
+ />
331
+ <Button variant="primary" size="sm" onClick={runConfiguredAction}>
332
+ {t(`kumiko.aiText.${dialogMode}`)}
333
+ </Button>
334
+ </div>
335
+ )}
336
+ {actionState === "loading" && (
337
+ <Text variant="muted">{t("kumiko.aiText.diff.generating")}</Text>
338
+ )}
339
+ {actionState === "success" && actionResult?.type === "text" && (
340
+ <div className="flex flex-col gap-3">
341
+ <div>
342
+ <Text variant="small">{t("kumiko.aiText.diff.before")}</Text>
343
+ <p className="whitespace-pre-wrap rounded-md border border-input bg-muted/40 p-2 text-sm">
344
+ {value}
345
+ </p>
346
+ </div>
347
+ <div>
348
+ <Text variant="small">{t("kumiko.aiText.diff.after")}</Text>
349
+ <p className="whitespace-pre-wrap rounded-md border border-input p-2 text-sm">
350
+ {actionResult.text}
351
+ </p>
352
+ </div>
353
+ </div>
354
+ )}
355
+ </Dialog>
356
+ </>
357
+ );
358
+ }
359
+
360
+ /** Single-line AI text field — ghost-text completion + correct/translate/
361
+ * rewrite toolbar. Degrades to a plain text field when `ai-text` isn't
362
+ * mounted server-side. */
363
+ export function AiTextField(props: AiTextFieldProps): ReactNode {
364
+ return <AiTextCore {...props} multiline={false} />;
365
+ }
366
+
367
+ /** Multi-line variant of {@link AiTextField}. */
368
+ export function AiTextArea(props: AiTextAreaProps): ReactNode {
369
+ return <AiTextCore {...props} multiline={true} />;
370
+ }
@@ -9,6 +9,9 @@ export function DetailList({
9
9
  testId,
10
10
  }: {
11
11
  readonly rows: readonly {
12
+ /** Optional stable key -- falls back to the label when omitted (fine as
13
+ * long as labels are unique; set id when they can repeat). */
14
+ readonly id?: string;
12
15
  readonly label: string;
13
16
  readonly value: ReactNode;
14
17
  readonly emphasize?: boolean;
@@ -19,7 +22,7 @@ export function DetailList({
19
22
  <dl data-testid={testId} className="flex flex-col divide-y">
20
23
  {rows.map((row) => (
21
24
  <div
22
- key={row.label}
25
+ key={row.id ?? row.label}
23
26
  className="grid grid-cols-1 gap-0.5 py-2.5 sm:grid-cols-[200px_1fr] sm:gap-4"
24
27
  >
25
28
  <dt
@@ -42,16 +42,11 @@ export function NumberField({
42
42
  );
43
43
  }
44
44
 
45
- // MoneyField/PercentField markieren die Feld-Absicht am Call-Site (lesbarer als
46
- // NumberField überall) und sind der Ort, an dem später geld-/prozent-spezifische
47
- // Formatierung andocken kann. Aktuell rendern sie identisch zu NumberField.
48
- export function MoneyField(props: NumberFieldProps): ReactNode {
49
- return <NumberField {...props} />;
50
- }
51
-
52
- export function PercentField(props: NumberFieldProps): ReactNode {
53
- return <NumberField {...props} />;
54
- }
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;
55
50
 
56
51
  interface FieldBase {
57
52
  readonly label: string;
@@ -139,7 +134,7 @@ export function SelectField({
139
134
 
140
135
  export interface DateFieldProps extends FieldBase {
141
136
  readonly value: string;
142
- readonly onChange: (v: string | undefined) => void;
137
+ readonly onChange: (v: string) => void;
143
138
  readonly min?: string;
144
139
  readonly max?: string;
145
140
  }
@@ -165,7 +160,7 @@ export function DateField({
165
160
  id={id}
166
161
  name={name}
167
162
  value={value}
168
- onChange={onChange}
163
+ onChange={(v) => onChange(v ?? "")}
169
164
  required={required}
170
165
  disabled={disabled}
171
166
  {...(min !== undefined && { min })}
@@ -290,6 +285,10 @@ export interface FileFieldProps extends FieldBase {
290
285
  readonly accept?: readonly string[];
291
286
  /** "image" zeigt eine Vorschau, "file" nur den Dateinamen. Default "file". */
292
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;
293
292
  }
294
293
 
295
294
  /** Datei-Upload = Field + Input(kind:"file"|"image") — FileRef-basiert. */
@@ -301,6 +300,8 @@ export function FileField({
301
300
  onChange,
302
301
  accept,
303
302
  variant = "file",
303
+ entityType,
304
+ fieldName,
304
305
  required,
305
306
  disabled,
306
307
  testId,
@@ -317,6 +318,8 @@ export function FileField({
317
318
  required={required}
318
319
  disabled={disabled}
319
320
  {...(accept !== undefined && { accept })}
321
+ {...(entityType !== undefined && { entityType })}
322
+ {...(fieldName !== undefined && { fieldName })}
320
323
  />
321
324
  </Field>
322
325
  );
@@ -3,6 +3,12 @@
3
3
  // Katalog: docs.kumiko.rocks → Guides → Widgets; visueller Überblick im
4
4
  // styleguide-Sample.
5
5
 
6
+ export {
7
+ AiTextArea,
8
+ type AiTextAreaProps,
9
+ AiTextField,
10
+ type AiTextFieldProps,
11
+ } from "./ai-text-field";
6
12
  export {
7
13
  StatusBarChart,
8
14
  type StatusBarEntry,
@@ -15,7 +15,8 @@ export function ModeSwitch<T extends string>({
15
15
  readonly testId?: string;
16
16
  }): ReactNode {
17
17
  return (
18
- <div data-testid={testId} className="flex flex-wrap gap-1">
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">
19
20
  {options.map((o) => {
20
21
  const active = o.value === value;
21
22
  return (