@cosmicdrift/kumiko-renderer-web 0.193.0 → 0.194.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.
Files changed (46) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/create-app.test.tsx +1 -1
  3. package/src/__tests__/entity-edit-redirect.test.tsx +111 -0
  4. package/src/__tests__/field-hide-label.test.tsx +166 -1
  5. package/src/__tests__/kumiko-screen.test.tsx +59 -0
  6. package/src/__tests__/nav-tree.test.tsx +43 -11
  7. package/src/__tests__/primitives.test.tsx +33 -2
  8. package/src/__tests__/render-edit.test.tsx +344 -9
  9. package/src/__tests__/test-utils.tsx +20 -0
  10. package/src/__tests__/wizard-form-validation.test.tsx +129 -1
  11. package/src/app/__tests__/merge-content-editors.test.ts +2 -2
  12. package/src/app/__tests__/plain-content-editor.test.tsx +4 -0
  13. package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
  14. package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
  15. package/src/app/client-plugin.tsx +3 -3
  16. package/src/app/create-app.tsx +40 -49
  17. package/src/app/tiptap-editor.tsx +10 -0
  18. package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
  19. package/src/layout/sidebar-panel.tsx +1 -4
  20. package/src/layout/workspace-switcher.tsx +20 -13
  21. package/src/lib/__tests__/resize-image.test.ts +152 -0
  22. package/src/lib/clamp.ts +3 -0
  23. package/src/lib/resize-image.ts +61 -0
  24. package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
  25. package/src/primitives/__tests__/date-field.test.tsx +37 -0
  26. package/src/primitives/__tests__/date-parse.test.ts +14 -2
  27. package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
  28. package/src/primitives/__tests__/file-upload.test.tsx +24 -0
  29. package/src/primitives/date-field.tsx +8 -0
  30. package/src/primitives/date-parse.ts +6 -1
  31. package/src/primitives/dropdown-menu.tsx +23 -0
  32. package/src/primitives/embedded-list-input.tsx +14 -5
  33. package/src/primitives/file-upload.tsx +6 -1
  34. package/src/primitives/index.tsx +114 -120
  35. package/src/primitives/money-input.tsx +4 -7
  36. package/src/ui/sheet.tsx +3 -1
  37. package/src/widgets/__tests__/drawer.test.tsx +293 -0
  38. package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
  39. package/src/widgets/__tests__/upload-zone.test.tsx +125 -1
  40. package/src/widgets/__tests__/widgets.test.tsx +25 -0
  41. package/src/widgets/ai-text-field.tsx +3 -1
  42. package/src/widgets/drawer.tsx +54 -29
  43. package/src/widgets/infinity-list.tsx +18 -5
  44. package/src/widgets/progress-bar.tsx +3 -1
  45. package/src/widgets/step-bar.tsx +1 -1
  46. package/src/widgets/upload-zone.tsx +42 -3
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { currencyDecimals } from "@cosmicdrift/kumiko-headless";
18
18
  import { Minus, Plus } from "lucide-react";
19
- import { type ReactNode, useEffect, useRef, useState } from "react";
19
+ import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
20
20
  import { cn } from "../lib/cn";
21
21
 
22
22
  // Re-exported for backward compat — callers used to import this from here
@@ -72,8 +72,7 @@ export function MoneyInput({
72
72
  const major = value === "" ? null : value / factor;
73
73
  const formatted = value === "" ? "" : formatMoney(value, currency, resolvedLocale);
74
74
 
75
- // Edit-Mode: Decimal-String ohne Tausender-Trenner. Konsistente Helper-
76
- // Funktion damit Focus-Init und Render-Fallback nicht auseinanderdriften.
75
+ // Edit-Mode: Decimal-String ohne Tausender-Trenner.
77
76
  const toEditable = (m: number | null): string =>
78
77
  m === null
79
78
  ? ""
@@ -83,8 +82,6 @@ export function MoneyInput({
83
82
  useGrouping: false,
84
83
  });
85
84
 
86
- const editable = focused ? draft : toEditable(major);
87
-
88
85
  const handleFocus = (): void => {
89
86
  setDraft(toEditable(major));
90
87
  setFocused(true);
@@ -102,7 +99,7 @@ export function MoneyInput({
102
99
  // click position from the formatted view to the editable one, which has
103
100
  // no well-defined equivalent once separators and the currency symbol
104
101
  // are stripped.
105
- useEffect(() => {
102
+ useLayoutEffect(() => {
106
103
  if (focused) inputRef.current?.select();
107
104
  }, [focused]);
108
105
 
@@ -134,7 +131,7 @@ export function MoneyInput({
134
131
  disabled={disabled}
135
132
  aria-required={required}
136
133
  aria-invalid={hasError === true ? true : undefined}
137
- value={focused ? editable : formatted}
134
+ value={focused ? draft : formatted}
138
135
  onFocus={handleFocus}
139
136
  onBlur={handleBlur}
140
137
  onChange={(e) => setDraft(e.target.value)}
package/src/ui/sheet.tsx CHANGED
@@ -48,6 +48,7 @@ function SheetOverlay({
48
48
  function SheetContent({
49
49
  className,
50
50
  overlayClassName,
51
+ overlayStyle,
51
52
  children,
52
53
  side = "right",
53
54
  showCloseButton = true,
@@ -56,10 +57,11 @@ function SheetContent({
56
57
  side?: "top" | "right" | "bottom" | "left"
57
58
  showCloseButton?: boolean
58
59
  overlayClassName?: string
60
+ overlayStyle?: React.CSSProperties
59
61
  }) {
60
62
  return (
61
63
  <SheetPortal>
62
- <SheetOverlay className={overlayClassName} />
64
+ <SheetOverlay className={overlayClassName} style={overlayStyle} />
63
65
  <SheetPrimitive.Content
64
66
  data-slot="sheet-content"
65
67
  className={cn(
@@ -32,4 +32,297 @@ describe("Drawer", () => {
32
32
  fireEvent.keyDown(screen.getByTestId("drawer"), { key: "Escape" });
33
33
  expect(onOpenChange).toHaveBeenCalledWith(false);
34
34
  });
35
+
36
+ describe("backdrop", () => {
37
+ function getOverlay(): HTMLElement {
38
+ const overlay = document.querySelector('[data-slot="sheet-overlay"]');
39
+ if (overlay === null) throw new Error("sheet-overlay not found");
40
+ return overlay as HTMLElement;
41
+ }
42
+
43
+ test("default: keine Blur, 20% Abdunklung", () => {
44
+ render(
45
+ <Drawer open={true} onOpenChange={() => {}} title="Mail" testId="drawer">
46
+ <div>Body</div>
47
+ </Drawer>,
48
+ );
49
+ const overlay = getOverlay();
50
+ expect(overlay.style.backdropFilter).toBe("");
51
+ expect(overlay.style.backgroundColor).toBe("rgb(0 0 0 / 20%)");
52
+ });
53
+
54
+ test("backdrop={{ blurPx: 4 }}: backdrop-filter blur(4px)", () => {
55
+ render(
56
+ <Drawer
57
+ open={true}
58
+ onOpenChange={() => {}}
59
+ title="Mail"
60
+ testId="drawer"
61
+ backdrop={{ blurPx: 4 }}
62
+ >
63
+ <div>Body</div>
64
+ </Drawer>,
65
+ );
66
+ expect(getOverlay().style.backdropFilter).toBe("blur(4px)");
67
+ });
68
+
69
+ test("backdrop={{ dimPercent: 0 }}: keine Abdunklung", () => {
70
+ render(
71
+ <Drawer
72
+ open={true}
73
+ onOpenChange={() => {}}
74
+ title="Mail"
75
+ testId="drawer"
76
+ backdrop={{ dimPercent: 0 }}
77
+ >
78
+ <div>Body</div>
79
+ </Drawer>,
80
+ );
81
+ expect(getOverlay().style.backgroundColor).toBe("rgb(0 0 0 / 0%)");
82
+ });
83
+ });
84
+
85
+ describe("resize", () => {
86
+ // happy-dom exposes innerWidth as an accessor (get/set) on the window
87
+ // instance. Object.defineProperty(...) with a plain `value` replaces
88
+ // that accessor with a data property, permanently — later test files
89
+ // sharing this single-process happy-dom then read a frozen, stale
90
+ // width instead of happy-dom's real viewport state.
91
+ // Capturing and restoring the exact original descriptor keeps the
92
+ // accessor (or its absence) intact for every test that runs after.
93
+ function withViewportWidth(px: number, run: () => void): void {
94
+ const originalDescriptor = Object.getOwnPropertyDescriptor(window, "innerWidth");
95
+ Object.defineProperty(window, "innerWidth", {
96
+ configurable: true,
97
+ writable: true,
98
+ value: px,
99
+ });
100
+ try {
101
+ run();
102
+ } finally {
103
+ if (originalDescriptor) {
104
+ Object.defineProperty(window, "innerWidth", originalDescriptor);
105
+ } else {
106
+ delete (window as unknown as { innerWidth?: number }).innerWidth;
107
+ }
108
+ }
109
+ }
110
+
111
+ test("clamps an out-of-range defaultWidthPx on initial render, before any interaction (fw#1965)", () => {
112
+ render(
113
+ <Drawer
114
+ open={true}
115
+ onOpenChange={() => {}}
116
+ side="right"
117
+ testId="drawer"
118
+ resize={{ defaultWidthPx: 1200, minWidthPx: 300, maxWidthPx: 800 }}
119
+ >
120
+ <div>Body</div>
121
+ </Drawer>,
122
+ );
123
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("800");
124
+ });
125
+ test("Startbreite folgt max(520px, 25vw) wenn resize.defaultWidthPx fehlt", () => {
126
+ withViewportWidth(3000, () => {
127
+ render(
128
+ <Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer" resize={{}}>
129
+ <div>Body</div>
130
+ </Drawer>,
131
+ );
132
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("750");
133
+ });
134
+ });
135
+
136
+ test("Startbreite clamped auf effectiveMaxWidthPx wenn 25vw ueber MAX_WIDTH_PX hinauslaeuft", () => {
137
+ withViewportWidth(8000, () => {
138
+ render(
139
+ <Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer" resize={{}}>
140
+ <div>Body</div>
141
+ </Drawer>,
142
+ );
143
+ const handle = screen.getByRole("separator");
144
+ const valueNow = Number(handle.getAttribute("aria-valuenow"));
145
+ const valueMax = Number(handle.getAttribute("aria-valuemax"));
146
+ expect(valueMax).toBe(1000);
147
+ expect(valueNow).toBe(valueMax);
148
+ expect(valueNow).toBeLessThanOrEqual(valueMax);
149
+ });
150
+ });
151
+
152
+ test("resize.defaultWidthPx wird ebenfalls gegen effectiveMaxWidthPx geclamped", () => {
153
+ withViewportWidth(2000, () => {
154
+ render(
155
+ <Drawer
156
+ open={true}
157
+ onOpenChange={() => {}}
158
+ side="right"
159
+ testId="drawer"
160
+ resize={{ defaultWidthPx: 5000 }}
161
+ >
162
+ <div>Body</div>
163
+ </Drawer>,
164
+ );
165
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("1000");
166
+ });
167
+ });
168
+
169
+ test("Startbreite clamped auf 520px Minimum bei schmalem Viewport", () => {
170
+ withViewportWidth(800, () => {
171
+ render(
172
+ <Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer" resize={{}}>
173
+ <div>Body</div>
174
+ </Drawer>,
175
+ );
176
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("520");
177
+ });
178
+ });
179
+
180
+ test("resize.defaultWidthPx schlägt die Viewport-Formel", () => {
181
+ withViewportWidth(3000, () => {
182
+ render(
183
+ <Drawer
184
+ open={true}
185
+ onOpenChange={() => {}}
186
+ side="right"
187
+ testId="drawer"
188
+ resize={{ defaultWidthPx: 600 }}
189
+ >
190
+ <div>Body</div>
191
+ </Drawer>,
192
+ );
193
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("600");
194
+ });
195
+ });
196
+
197
+ test("side='right': ArrowLeft grows, ArrowRight shrinks", () => {
198
+ render(
199
+ <Drawer
200
+ open={true}
201
+ onOpenChange={() => {}}
202
+ side="right"
203
+ testId="drawer"
204
+ resize={{ defaultWidthPx: 400, minWidthPx: 300, maxWidthPx: 500 }}
205
+ >
206
+ <div>Body</div>
207
+ </Drawer>,
208
+ );
209
+ const handle = screen.getByRole("separator");
210
+ expect(handle.getAttribute("aria-valuenow")).toBe("400");
211
+ fireEvent.keyDown(handle, { key: "ArrowLeft" });
212
+ expect(handle.getAttribute("aria-valuenow")).toBe("416");
213
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
214
+ expect(handle.getAttribute("aria-valuenow")).toBe("400");
215
+ });
216
+
217
+ test("side='left': ArrowRight grows, ArrowLeft shrinks (direction mirrored)", () => {
218
+ render(
219
+ <Drawer
220
+ open={true}
221
+ onOpenChange={() => {}}
222
+ side="left"
223
+ testId="drawer"
224
+ resize={{ defaultWidthPx: 400, minWidthPx: 300, maxWidthPx: 500 }}
225
+ >
226
+ <div>Body</div>
227
+ </Drawer>,
228
+ );
229
+ const handle = screen.getByRole("separator");
230
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
231
+ expect(handle.getAttribute("aria-valuenow")).toBe("416");
232
+ fireEvent.keyDown(handle, { key: "ArrowLeft" });
233
+ expect(handle.getAttribute("aria-valuenow")).toBe("400");
234
+ });
235
+
236
+ test("clamps growth at maxWidthPx and shrink at minWidthPx (shift = 40px step)", () => {
237
+ render(
238
+ <Drawer
239
+ open={true}
240
+ onOpenChange={() => {}}
241
+ side="right"
242
+ testId="drawer"
243
+ resize={{ defaultWidthPx: 490, minWidthPx: 300, maxWidthPx: 500 }}
244
+ >
245
+ <div>Body</div>
246
+ </Drawer>,
247
+ );
248
+ const handle = screen.getByRole("separator");
249
+ fireEvent.keyDown(handle, { key: "ArrowLeft", shiftKey: true });
250
+ expect(handle.getAttribute("aria-valuenow")).toBe("500");
251
+ for (let i = 0; i < 20; i++) {
252
+ fireEvent.keyDown(handle, { key: "ArrowRight", shiftKey: true });
253
+ }
254
+ expect(handle.getAttribute("aria-valuenow")).toBe("300");
255
+ });
256
+
257
+ test("maximize toggle switches aria-pressed and expands to maxWidthPx", () => {
258
+ render(
259
+ <Drawer
260
+ open={true}
261
+ onOpenChange={() => {}}
262
+ side="right"
263
+ testId="drawer"
264
+ resize={{ defaultWidthPx: 400, minWidthPx: 300, maxWidthPx: 500 }}
265
+ >
266
+ <div>Body</div>
267
+ </Drawer>,
268
+ );
269
+ const maximizeButton = screen.getByRole("button", { name: /maximize drawer width/i });
270
+ expect(maximizeButton.getAttribute("aria-pressed")).toBe("false");
271
+ fireEvent.click(maximizeButton);
272
+ expect(maximizeButton.getAttribute("aria-pressed")).toBe("true");
273
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("500");
274
+ fireEvent.click(maximizeButton);
275
+ expect(maximizeButton.getAttribute("aria-pressed")).toBe("false");
276
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("400");
277
+ });
278
+
279
+ test("arrow key after maximize resizes from the maximized width, not the pre-maximize width", () => {
280
+ render(
281
+ <Drawer
282
+ open={true}
283
+ onOpenChange={() => {}}
284
+ side="right"
285
+ testId="drawer"
286
+ resize={{ defaultWidthPx: 400, minWidthPx: 300, maxWidthPx: 500 }}
287
+ >
288
+ <div>Body</div>
289
+ </Drawer>,
290
+ );
291
+ const maximizeButton = screen.getByRole("button", { name: /maximize drawer width/i });
292
+ fireEvent.click(maximizeButton);
293
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("500");
294
+
295
+ const handle = screen.getByRole("separator");
296
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
297
+ // Shrinking from the visible 500px maximized width, not from the
298
+ // stale 400px `width` state that was never updated while maximized.
299
+ expect(handle.getAttribute("aria-valuenow")).toBe("484");
300
+ });
301
+
302
+ test("side='top' with resize set: no resize handle, no maximize button (vertical drawers can't resize)", () => {
303
+ render(
304
+ <Drawer
305
+ open={true}
306
+ onOpenChange={() => {}}
307
+ side="top"
308
+ testId="drawer"
309
+ resize={{ defaultWidthPx: 400 }}
310
+ >
311
+ <div>Body</div>
312
+ </Drawer>,
313
+ );
314
+ expect(screen.queryByRole("separator")).toBeNull();
315
+ expect(screen.queryByRole("button", { name: /maximize drawer width/i })).toBeNull();
316
+ });
317
+
318
+ test("resize prop omitted: no resize handle, no maximize button", () => {
319
+ render(
320
+ <Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer">
321
+ <div>Body</div>
322
+ </Drawer>,
323
+ );
324
+ expect(screen.queryByRole("separator")).toBeNull();
325
+ expect(screen.queryByRole("button", { name: /maximize drawer width/i })).toBeNull();
326
+ });
327
+ });
35
328
  });
@@ -123,6 +123,44 @@ describe("InfinityList", () => {
123
123
  expect(calls).toBe(2);
124
124
  });
125
125
 
126
+ test("dedupliziert nach rowId beim Append — eine Row die auf Seite 2 erneut auftaucht wird nicht doppelt gerendert (fw#1829)", async () => {
127
+ let calls = 0;
128
+ const dispatcher = createMockDispatcher({
129
+ query: (async () => {
130
+ calls += 1;
131
+ if (calls === 1) {
132
+ return {
133
+ isSuccess: true,
134
+ data: {
135
+ rows: [
136
+ { id: "m1", subject: "Erste" },
137
+ { id: "m2", subject: "Zweite" },
138
+ ],
139
+ nextCursor: "c1",
140
+ },
141
+ };
142
+ }
143
+ // Offset-based cursor re-serves m2 (already on page 1, e.g. after a
144
+ // live-merge shifted the offset) alongside a genuinely new row.
145
+ return {
146
+ isSuccess: true,
147
+ data: {
148
+ rows: [
149
+ { id: "m2", subject: "Zweite" },
150
+ { id: "m3", subject: "Dritte" },
151
+ ],
152
+ nextCursor: null,
153
+ },
154
+ };
155
+ }) as unknown as Dispatcher["query"],
156
+ });
157
+ renderWithDispatcher(list("inbox:query:message:list"), dispatcher);
158
+ await waitFor(() => expect(screen.getByText("Zweite")).toBeTruthy());
159
+ fireIntersect();
160
+ await waitFor(() => expect(screen.getByText("Dritte")).toBeTruthy());
161
+ expect(screen.getAllByText("Zweite")).toHaveLength(1);
162
+ });
163
+
126
164
  test("Fehler → ErrorState, Retry lädt neu", async () => {
127
165
  let calls = 0;
128
166
  const dispatcher = createMockDispatcher({
@@ -49,9 +49,133 @@ describe("UploadZone", () => {
49
49
  const onUpload = mock(async () => {});
50
50
  render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
51
51
  const input = screen.getByTestId("zone-input") as HTMLInputElement;
52
+ // "files" is stubbed the same way; a plain `expect(input.value).toBe("")`
53
+ // is vacuous because happy-dom never wrote anything else there — a spy on
54
+ // the setter is what actually proves the component performs the reset.
55
+ const setValue = mock((_value: string) => {});
56
+ Object.defineProperty(input, "value", { set: setValue, get: () => "", configurable: true });
52
57
  pick(input, [new File(["a"], "a.pdf")]);
53
58
 
54
59
  await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
55
- expect(input.value).toBe("");
60
+ await waitFor(() => expect(setValue).toHaveBeenCalledWith(""));
61
+ });
62
+
63
+ test("ruft onUpload bei Drop mit Dateien auf", async () => {
64
+ const onUpload = mock(async () => {});
65
+ render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
66
+ const file = new File(["hi"], "dropped.pdf", { type: "application/pdf" });
67
+ fireEvent.drop(screen.getByTestId("zone-dropzone"), { dataTransfer: { files: [file] } });
68
+
69
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
70
+ expect(onUpload).toHaveBeenCalledWith(file);
71
+ });
72
+
73
+ test("a dropped file that doesn't match `accept` is rejected, not passed to onUpload", async () => {
74
+ const onUpload = mock(async () => {});
75
+ render(
76
+ <UploadZone title="Datei hochladen" onUpload={onUpload} accept={["image/*"]} testId="zone" />,
77
+ );
78
+ const file = new File(["hi"], "dropped.pdf", { type: "application/pdf" });
79
+ fireEvent.drop(screen.getByTestId("zone-dropzone"), { dataTransfer: { files: [file] } });
80
+
81
+ await waitFor(() => expect(screen.getByText("dropped.pdf")).toBeTruthy());
82
+ expect(screen.getByText("File type not allowed")).toBeTruthy();
83
+ expect(onUpload).not.toHaveBeenCalled();
84
+ });
85
+
86
+ test("a dropped file that matches `accept` still uploads normally", async () => {
87
+ const onUpload = mock(async () => {});
88
+ render(
89
+ <UploadZone title="Datei hochladen" onUpload={onUpload} accept={["image/*"]} testId="zone" />,
90
+ );
91
+ const file = new File(["hi"], "photo.png", { type: "image/png" });
92
+ fireEvent.drop(screen.getByTestId("zone-dropzone"), { dataTransfer: { files: [file] } });
93
+
94
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
95
+ expect(onUpload).toHaveBeenCalledWith(file);
96
+ });
97
+
98
+ test("a non-Error throw from onUpload shows the translated fallback, not the raw token", async () => {
99
+ const onUpload = mock(async () => {
100
+ throw "upload_failed";
101
+ });
102
+ render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
103
+ const file = new File(["hi"], "huge.pdf", { type: "application/pdf" });
104
+ pick(screen.getByTestId("zone-input"), [file]);
105
+
106
+ // Both the sr-only status label and the visible error line now read
107
+ // "Failed" (same i18n key) — assert at least one is present rather than
108
+ // requiring a single match.
109
+ await waitFor(() => expect(screen.getAllByText("Failed").length).toBeGreaterThan(0));
110
+ expect(screen.queryByText("upload_failed")).toBeNull();
111
+ });
112
+
113
+ test("disabled unterdrückt den Drop", () => {
114
+ const onUpload = mock(async () => {});
115
+ render(<UploadZone title="Datei hochladen" onUpload={onUpload} disabled testId="zone" />);
116
+ const file = new File(["hi"], "dropped.pdf", { type: "application/pdf" });
117
+ fireEvent.drop(screen.getByTestId("zone-dropzone"), { dataTransfer: { files: [file] } });
118
+
119
+ expect(onUpload).not.toHaveBeenCalled();
120
+ });
121
+
122
+ test("dragLeave auf ein Kind-Element behält den aktiven State, auf ein Element außerhalb setzt ihn zurück", () => {
123
+ const onUpload = mock(async () => {});
124
+ render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
125
+ const dropzone = screen.getByTestId("zone-dropzone");
126
+ const childIcon = screen.getByText("Datei hochladen");
127
+
128
+ fireEvent.dragOver(dropzone, { dataTransfer: {} });
129
+ expect(dropzone.className).toContain("border-primary");
130
+
131
+ // happy-dom's DragEvent constructor ignores `relatedTarget` in the init
132
+ // dict, so it must be forced via defineProperty rather than passed
133
+ // through fireEvent.dragLeave(el, init).
134
+ const leaveTo = (relatedTarget: Element): void => {
135
+ const evt = new DragEvent("dragleave", { bubbles: true, cancelable: true });
136
+ Object.defineProperty(evt, "relatedTarget", { value: relatedTarget, configurable: true });
137
+ fireEvent(dropzone, evt);
138
+ };
139
+
140
+ leaveTo(childIcon);
141
+ expect(dropzone.className).toContain("border-primary");
142
+
143
+ leaveTo(document.body);
144
+ expect(dropzone.className).not.toContain("border-primary");
145
+ });
146
+
147
+ test("gibt Bilder verkleinert an onUpload weiter (nicht die Originaldatei)", async () => {
148
+ class FakeOffscreenCanvas {
149
+ getContext() {
150
+ return { drawImage: mock(() => {}) };
151
+ }
152
+ convertToBlob() {
153
+ return Promise.resolve(new Blob(["resized-bytes"], { type: "image/jpeg" }));
154
+ }
155
+ }
156
+ // @ts-expect-error test stub for a browser-only API missing in happy-dom
157
+ globalThis.OffscreenCanvas = FakeOffscreenCanvas;
158
+ globalThis.createImageBitmap = mock(async () => ({
159
+ close: mock(() => {}),
160
+ height: 100,
161
+ width: 100,
162
+ }));
163
+
164
+ try {
165
+ const onUpload = mock(async (_uploaded: File) => {});
166
+ render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
167
+ const file = new File(["x"], "photo.jpg", { type: "image/jpeg" });
168
+ pick(screen.getByTestId("zone-input"), [file]);
169
+
170
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
171
+ const [uploaded] = onUpload.mock.calls[0] ?? [];
172
+ expect(uploaded?.size).toBe("resized-bytes".length);
173
+ expect(uploaded?.size).not.toBe(file.size);
174
+ } finally {
175
+ // @ts-expect-error restore missing-API baseline
176
+ globalThis.OffscreenCanvas = undefined;
177
+ // @ts-expect-error restore missing-API baseline
178
+ globalThis.createImageBitmap = undefined;
179
+ }
56
180
  });
57
181
  });
@@ -44,6 +44,23 @@ describe("ProgressBar", () => {
44
44
  expect(screen.getByTestId("bar").getAttribute("aria-valuenow")).toBe("0");
45
45
  });
46
46
 
47
+ test("NaN (z.B. aus done/total mit total=0) wird 0 statt NaN durchzureichen", () => {
48
+ render(<ProgressBar value={Number.NaN} testId="bar" />);
49
+ const bar = screen.getByTestId("bar");
50
+ expect(bar.getAttribute("aria-valuenow")).toBe("0");
51
+ const fill = bar.firstElementChild as HTMLElement;
52
+ expect(fill.style.width).toBe("0%");
53
+ });
54
+
55
+ test("className landet auf dem Wrapper, h-2 bleibt auf dem Testid-/Track-Element (fw#1970)", () => {
56
+ render(<ProgressBar value={0.5} testId="bar" className="mt-4" />);
57
+ const bar = screen.getByTestId("bar");
58
+ expect(bar.parentElement?.className).toContain("mt-4");
59
+ expect(bar.parentElement?.className).toContain("w-full");
60
+ expect(bar.className).toContain("h-2");
61
+ expect(bar.className).not.toContain("mt-4");
62
+ });
63
+
47
64
  test("Füll-Element bildet den Wert über Breite ab und erbt die Höhe nicht vom Elternteil", () => {
48
65
  render(<ProgressBar value={0.5} testId="bar" />);
49
66
  const bar = screen.getByTestId("bar");
@@ -54,6 +71,14 @@ describe("ProgressBar", () => {
54
71
  expect(fill.className).toContain("inset-y-0");
55
72
  expect(bar.className).toContain("relative");
56
73
  });
74
+
75
+ test("die testId sitzt auf der Track-Höhe, className erreicht den äußeren Wrapper", () => {
76
+ render(<ProgressBar value={0.5} testId="bar" className="mb-4" />);
77
+ const track = screen.getByTestId("bar");
78
+ const wrapper = track.parentElement as HTMLElement;
79
+ expect(wrapper.className).toContain("mb-4");
80
+ expect(track.className).toContain("h-2");
81
+ });
57
82
  });
58
83
 
59
84
  describe("StepBar", () => {
@@ -45,6 +45,7 @@ interface AiTextFieldBaseProps {
45
45
  readonly disabled?: boolean;
46
46
  readonly placeholder?: string;
47
47
  readonly testId?: string;
48
+ readonly hideLabel?: boolean;
48
49
  /** Ghost-text completion on/off. Default true. */
49
50
  readonly completion?: boolean;
50
51
  /** Debounce for ghost-text requests, ms. Default 500 — kept low in
@@ -88,6 +89,7 @@ function AiTextCore({
88
89
  disabled,
89
90
  placeholder,
90
91
  testId,
92
+ hideLabel,
91
93
  rows,
92
94
  completion = true,
93
95
  completionDebounceMs = 500,
@@ -183,7 +185,7 @@ function AiTextCore({
183
185
 
184
186
  return (
185
187
  <>
186
- <Field id={id} label={label} required={required} testId={testId}>
188
+ <Field id={id} label={label} required={required} hideLabel={hideLabel} testId={testId}>
187
189
  <div className="relative w-full">
188
190
  {showGhost && (
189
191
  <div