@cosmicdrift/kumiko-renderer-web 0.193.1 → 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.
- package/package.json +4 -4
- package/src/__tests__/create-app.test.tsx +1 -1
- package/src/__tests__/entity-edit-redirect.test.tsx +111 -0
- package/src/__tests__/field-hide-label.test.tsx +166 -1
- package/src/__tests__/kumiko-screen.test.tsx +59 -0
- package/src/__tests__/nav-tree.test.tsx +43 -11
- package/src/__tests__/primitives.test.tsx +33 -2
- package/src/__tests__/render-edit.test.tsx +344 -9
- package/src/__tests__/test-utils.tsx +20 -0
- package/src/__tests__/wizard-form-validation.test.tsx +129 -1
- package/src/app/__tests__/merge-content-editors.test.ts +2 -2
- package/src/app/__tests__/plain-content-editor.test.tsx +4 -0
- package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
- package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
- package/src/app/client-plugin.tsx +3 -3
- package/src/app/create-app.tsx +40 -49
- package/src/app/tiptap-editor.tsx +10 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
- package/src/layout/sidebar-panel.tsx +1 -4
- package/src/layout/workspace-switcher.tsx +20 -13
- package/src/lib/clamp.ts +3 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
- package/src/primitives/__tests__/date-field.test.tsx +37 -0
- package/src/primitives/__tests__/date-parse.test.ts +14 -2
- package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
- package/src/primitives/__tests__/file-upload.test.tsx +24 -0
- package/src/primitives/date-field.tsx +8 -0
- package/src/primitives/date-parse.ts +6 -1
- package/src/primitives/dropdown-menu.tsx +23 -0
- package/src/primitives/embedded-list-input.tsx +14 -5
- package/src/primitives/file-upload.tsx +3 -0
- package/src/primitives/index.tsx +114 -120
- package/src/primitives/money-input.tsx +4 -7
- package/src/ui/sheet.tsx +3 -1
- package/src/widgets/__tests__/drawer.test.tsx +293 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +90 -1
- package/src/widgets/__tests__/widgets.test.tsx +25 -0
- package/src/widgets/ai-text-field.tsx +3 -1
- package/src/widgets/drawer.tsx +54 -29
- package/src/widgets/infinity-list.tsx +18 -5
- package/src/widgets/progress-bar.tsx +3 -1
- package/src/widgets/step-bar.tsx +1 -1
- package/src/widgets/upload-zone.tsx +40 -2
|
@@ -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,10 +49,99 @@ 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(
|
|
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");
|
|
56
145
|
});
|
|
57
146
|
|
|
58
147
|
test("gibt Bilder verkleinert an onUpload weiter (nicht die Originaldatei)", async () => {
|
|
@@ -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
|
package/src/widgets/drawer.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
|
|
2
2
|
import { type ReactNode, useRef, useState } from "react";
|
|
3
|
+
import { clamp } from "../lib/clamp";
|
|
3
4
|
import { cn } from "../lib/cn";
|
|
4
5
|
import {
|
|
5
6
|
Sheet,
|
|
@@ -20,39 +21,49 @@ export type DrawerProps = {
|
|
|
20
21
|
readonly children: ReactNode;
|
|
21
22
|
readonly testId?: string;
|
|
22
23
|
/** Opt-in drag-to-resize + maximize toggle (left/right sides only). */
|
|
23
|
-
readonly
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
readonly resize?: {
|
|
25
|
+
readonly defaultWidthPx?: number;
|
|
26
|
+
readonly minWidthPx?: number;
|
|
27
|
+
readonly maxWidthPx?: number;
|
|
28
|
+
};
|
|
29
|
+
/** Backdrop behind the panel. A drawer exists so the content behind it
|
|
30
|
+
* stays readable, so the blur is off by default. */
|
|
31
|
+
readonly backdrop?: {
|
|
32
|
+
readonly blurPx?: number;
|
|
33
|
+
readonly dimPercent?: number;
|
|
34
|
+
};
|
|
27
35
|
};
|
|
28
36
|
|
|
29
|
-
const DEFAULT_WIDTH_PX = 420;
|
|
30
37
|
const MIN_WIDTH_PX = 320;
|
|
31
|
-
const MAX_WIDTH_PX =
|
|
38
|
+
const MAX_WIDTH_PX = 1000;
|
|
39
|
+
// Must match the static `max(520px,25vw)` in floatingSideClass — Tailwind's
|
|
40
|
+
// JIT can't read these at runtime, so the two are kept in sync by hand.
|
|
41
|
+
const DEFAULT_WIDTH_MIN_PX = 520;
|
|
42
|
+
const DEFAULT_WIDTH_VIEWPORT_RATIO = 0.25;
|
|
43
|
+
const DEFAULT_BLUR_PX = 0;
|
|
44
|
+
const DEFAULT_DIM_PERCENT = 20;
|
|
45
|
+
|
|
46
|
+
function defaultWidthFromViewport(): number {
|
|
47
|
+
return typeof window === "undefined"
|
|
48
|
+
? DEFAULT_WIDTH_MIN_PX
|
|
49
|
+
: Math.max(DEFAULT_WIDTH_MIN_PX, Math.round(window.innerWidth * DEFAULT_WIDTH_VIEWPORT_RATIO));
|
|
50
|
+
}
|
|
32
51
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// per-side classes. twMerge resolves each utility group against the base
|
|
36
|
-
// (inset/width/height/border/rounding), so this fully overrides rather than
|
|
37
|
-
// stacking with it. 32px margin + 32px radius so the detachment from the
|
|
38
|
-
// viewport edge reads clearly at a glance, not just on close 1:1 inspection.
|
|
52
|
+
// 32px margin + 32px radius so the panel reads as detached from the
|
|
53
|
+
// viewport edge, unlike the sheet primitive's flush-edge default.
|
|
39
54
|
function floatingSideClass(side: "left" | "right" | "top" | "bottom"): string {
|
|
40
55
|
switch (side) {
|
|
41
56
|
case "left":
|
|
42
|
-
return "inset-y-8 left-8 h-auto w-[
|
|
57
|
+
return "inset-y-8 left-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl";
|
|
43
58
|
case "top":
|
|
44
59
|
return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
45
60
|
case "bottom":
|
|
46
61
|
return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
47
62
|
default:
|
|
48
|
-
return "inset-y-8 right-8 h-auto w-[
|
|
63
|
+
return "inset-y-8 right-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl";
|
|
49
64
|
}
|
|
50
65
|
}
|
|
51
66
|
|
|
52
|
-
function clamp(value: number, min: number, max: number): number {
|
|
53
|
-
return Math.min(Math.max(value, min), max);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
67
|
/** Slide-in panel beside a list (e.g. mail reader next to the inbox) —
|
|
57
68
|
* thin wrapper over the Sheet primitive with header/body/footer slots
|
|
58
69
|
* so screens skip per-route Radix boilerplate. */
|
|
@@ -65,20 +76,28 @@ export function Drawer({
|
|
|
65
76
|
footer,
|
|
66
77
|
children,
|
|
67
78
|
testId,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
minWidthPx = MIN_WIDTH_PX,
|
|
71
|
-
maxWidthPx = MAX_WIDTH_PX,
|
|
79
|
+
resize,
|
|
80
|
+
backdrop,
|
|
72
81
|
}: DrawerProps): ReactNode {
|
|
73
|
-
const canResize =
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
77
|
-
|
|
82
|
+
const canResize = resize !== undefined && (side === "left" || side === "right");
|
|
83
|
+
const minWidthPx = resize?.minWidthPx ?? MIN_WIDTH_PX;
|
|
84
|
+
const maxWidthPx = resize?.maxWidthPx ?? MAX_WIDTH_PX;
|
|
78
85
|
const effectiveMaxWidthPx = () =>
|
|
79
86
|
typeof window === "undefined"
|
|
80
87
|
? maxWidthPx
|
|
81
88
|
: Math.min(maxWidthPx, Math.round(window.innerWidth * 0.9));
|
|
89
|
+
const [width, setWidth] = useState(() =>
|
|
90
|
+
clamp(resize?.defaultWidthPx ?? defaultWidthFromViewport(), minWidthPx, effectiveMaxWidthPx()),
|
|
91
|
+
);
|
|
92
|
+
const [maximized, setMaximized] = useState(false);
|
|
93
|
+
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
94
|
+
const blurPx = backdrop?.blurPx ?? DEFAULT_BLUR_PX;
|
|
95
|
+
const dimPercent = backdrop?.dimPercent ?? DEFAULT_DIM_PERCENT;
|
|
96
|
+
const overlayStyle: React.CSSProperties = {
|
|
97
|
+
backgroundColor: `rgb(0 0 0 / ${dimPercent}%)`,
|
|
98
|
+
...(blurPx > 0 ? { backdropFilter: `blur(${blurPx}px)` } : {}),
|
|
99
|
+
};
|
|
100
|
+
|
|
82
101
|
const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : width;
|
|
83
102
|
|
|
84
103
|
const onHandlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
@@ -104,7 +123,13 @@ export function Drawer({
|
|
|
104
123
|
event.preventDefault();
|
|
105
124
|
setMaximized(false);
|
|
106
125
|
const delta = event.key === grow ? step : -step;
|
|
107
|
-
|
|
126
|
+
// Seed from the currently visible width, not the stale `width` state —
|
|
127
|
+
// while maximized, `width` still holds the pre-maximize value, so a key
|
|
128
|
+
// press would otherwise jump the drawer back to that old size instead
|
|
129
|
+
// of resizing relative to what's on screen.
|
|
130
|
+
setWidth((current) =>
|
|
131
|
+
clamp((maximized ? effectiveWidthPx : current) + delta, minWidthPx, effectiveMaxWidthPx()),
|
|
132
|
+
);
|
|
108
133
|
};
|
|
109
134
|
|
|
110
135
|
return (
|
|
@@ -112,7 +137,7 @@ export function Drawer({
|
|
|
112
137
|
<SheetContent
|
|
113
138
|
side={side}
|
|
114
139
|
data-testid={testId}
|
|
115
|
-
|
|
140
|
+
overlayStyle={overlayStyle}
|
|
116
141
|
className={floatingSideClass(side)}
|
|
117
142
|
style={canResize ? { width: effectiveWidthPx, maxWidth: "none" } : undefined}
|
|
118
143
|
>
|
|
@@ -106,11 +106,24 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
106
106
|
nextRows.map((row, index) => rowIdRef.current(row, index)),
|
|
107
107
|
);
|
|
108
108
|
}
|
|
109
|
-
setState((prev) =>
|
|
110
|
-
kind
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
setState((prev) => {
|
|
110
|
+
if (cursor === null || prev.kind !== "ready") {
|
|
111
|
+
return { kind: "ready", rows: nextRows, cursor: nextCursorRef.current(res.data) };
|
|
112
|
+
}
|
|
113
|
+
// The live-merge above can re-sort a row to the front of page 1; an
|
|
114
|
+
// offset-based cursor then re-serves that same row in a later page.
|
|
115
|
+
// Dedupe on append so it doesn't land twice under duplicate React
|
|
116
|
+
// keys.
|
|
117
|
+
const existingIds = new Set(prev.rows.map((row, index) => rowIdRef.current(row, index)));
|
|
118
|
+
const newRows = nextRows.filter(
|
|
119
|
+
(row, index) => !existingIds.has(rowIdRef.current(row, index)),
|
|
120
|
+
);
|
|
121
|
+
return {
|
|
122
|
+
kind: "ready",
|
|
123
|
+
rows: [...prev.rows, ...newRows],
|
|
124
|
+
cursor: nextCursorRef.current(res.data),
|
|
125
|
+
};
|
|
126
|
+
});
|
|
114
127
|
},
|
|
115
128
|
[dispatcher, query, pageSize, payloadKey],
|
|
116
129
|
);
|