@cosmicdrift/kumiko-renderer-web 0.193.1 → 0.195.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 +10 -2
- package/src/__tests__/entity-edit-redirect.test.tsx +143 -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 +397 -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 +28 -3
- package/src/app/__tests__/rich-content-editor.test.tsx +6 -2
- package/src/app/__tests__/tiptap-editor.test.tsx +102 -7
- package/src/app/client-plugin.tsx +3 -3
- package/src/app/create-app.tsx +40 -49
- package/src/app/plain-content-editor.tsx +9 -8
- package/src/app/tiptap-editor.tsx +40 -12
- package/src/index.ts +1 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +62 -2
- package/src/layout/sidebar-panel.tsx +1 -4
- package/src/layout/workspace-switcher.tsx +26 -13
- package/src/lib/__tests__/resize-image.test.ts +42 -3
- package/src/lib/clamp.ts +3 -0
- package/src/lib/resize-image.ts +6 -1
- 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 +77 -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 +23 -13
- 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/primitives/use-narrow-viewport.ts +28 -0
- package/src/ui/sheet.tsx +3 -1
- package/src/widgets/__tests__/drawer.test.tsx +315 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +117 -2
- package/src/widgets/__tests__/widgets.test.tsx +25 -0
- package/src/widgets/ai-text-field.tsx +3 -1
- package/src/widgets/drawer.tsx +62 -31
- 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 +45 -3
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
// callback. Radix opens on pointerdown → userEvent instead of
|
|
7
7
|
// fireEvent.click, same as language-switcher.test.tsx.
|
|
8
8
|
|
|
9
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
9
|
+
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
10
10
|
import {
|
|
11
11
|
createStaticLocaleResolver,
|
|
12
12
|
LocaleProvider,
|
|
13
13
|
type WorkspaceSchema,
|
|
14
14
|
} from "@cosmicdrift/kumiko-renderer";
|
|
15
15
|
import userEvent from "@testing-library/user-event";
|
|
16
|
-
import { renderWithSidebar, screen } from "../../__tests__/test-utils";
|
|
16
|
+
import { render, renderWithSidebar, screen } from "../../__tests__/test-utils";
|
|
17
17
|
import { WorkspaceSwitcher } from "../workspace-switcher";
|
|
18
18
|
|
|
19
19
|
function ws(id: string, label = id): WorkspaceSchema {
|
|
@@ -55,6 +55,66 @@ describe("WorkspaceSwitcher — Render", () => {
|
|
|
55
55
|
expect(screen.getByTestId("workspace-tab-b").getAttribute("aria-checked")).toBe("true");
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
test("entries carry role=menuitemradio (exclusive choice), not menuitemcheckbox", async () => {
|
|
59
|
+
const user = userEvent.setup();
|
|
60
|
+
renderWithSidebar(
|
|
61
|
+
<WorkspaceSwitcher
|
|
62
|
+
workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
|
|
63
|
+
activeId="a"
|
|
64
|
+
onSelect={() => {}}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
68
|
+
expect(screen.getByTestId("workspace-tab-a").getAttribute("role")).toBe("menuitemradio");
|
|
69
|
+
expect(screen.getByTestId("workspace-tab-b").getAttribute("role")).toBe("menuitemradio");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("trigger has an accessible name beyond the active workspace label", () => {
|
|
73
|
+
renderWithSidebar(
|
|
74
|
+
<WorkspaceSwitcher
|
|
75
|
+
workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
|
|
76
|
+
activeId="a"
|
|
77
|
+
onSelect={() => {}}
|
|
78
|
+
/>,
|
|
79
|
+
);
|
|
80
|
+
expect(
|
|
81
|
+
screen.getByTestId("workspace-switcher-trigger").getAttribute("aria-label"),
|
|
82
|
+
).toBeTruthy();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("activeId zeigt auf keinen sichtbaren Workspace → Trigger zeigt Fallback-Label statt leerem span (fw#1816)", () => {
|
|
86
|
+
renderWithSidebar(
|
|
87
|
+
<WorkspaceSwitcher
|
|
88
|
+
workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
|
|
89
|
+
activeId="stale-id-not-in-list"
|
|
90
|
+
onSelect={() => {}}
|
|
91
|
+
testId="sw"
|
|
92
|
+
/>,
|
|
93
|
+
);
|
|
94
|
+
expect(screen.getByTestId("sw").textContent).not.toBe("");
|
|
95
|
+
expect(screen.getByText("Select workspace")).toBeTruthy();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("rendering outside a SidebarProvider throws — the JSDoc requirement is a real crash, not just documentation (fw#1816)", () => {
|
|
99
|
+
// SidebarMenuButton calls useSidebar() internally; without a
|
|
100
|
+
// SidebarProvider ancestor that throws synchronously during render.
|
|
101
|
+
// Silence the expected console.error noise React logs alongside it.
|
|
102
|
+
const consoleError = spyOn(console, "error").mockImplementation(() => {});
|
|
103
|
+
try {
|
|
104
|
+
expect(() =>
|
|
105
|
+
render(
|
|
106
|
+
<WorkspaceSwitcher
|
|
107
|
+
workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
|
|
108
|
+
activeId="a"
|
|
109
|
+
onSelect={() => {}}
|
|
110
|
+
/>,
|
|
111
|
+
),
|
|
112
|
+
).toThrow(/useSidebar must be used within a SidebarProvider/);
|
|
113
|
+
} finally {
|
|
114
|
+
consoleError.mockRestore();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
58
118
|
test("Click auf einen Eintrag ruft onSelect mit der Workspace-id", async () => {
|
|
59
119
|
const user = userEvent.setup();
|
|
60
120
|
const onSelect = mock((_id: string) => {});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
useState,
|
|
23
23
|
} from "react";
|
|
24
24
|
import { createPortal } from "react-dom";
|
|
25
|
+
import { clamp } from "../lib/clamp";
|
|
25
26
|
import { cn } from "../lib/cn";
|
|
26
27
|
|
|
27
28
|
type SlotElement = HTMLElement | null;
|
|
@@ -57,10 +58,6 @@ const DEFAULT_WIDTH = 340;
|
|
|
57
58
|
const DEFAULT_MIN = 260;
|
|
58
59
|
const DEFAULT_MAX = 640;
|
|
59
60
|
|
|
60
|
-
function clamp(value: number, min: number, max: number): number {
|
|
61
|
-
return Math.min(Math.max(value, min), max);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
61
|
function readStoredWidth(key: string | undefined): number | null {
|
|
65
62
|
if (key === undefined || typeof window === "undefined") return null;
|
|
66
63
|
const raw = window.localStorage.getItem(key);
|
|
@@ -20,8 +20,9 @@ import { ChevronsUpDown } from "lucide-react";
|
|
|
20
20
|
import type { ReactNode } from "react";
|
|
21
21
|
import {
|
|
22
22
|
DropdownMenu,
|
|
23
|
-
DropdownMenuCheckboxItem,
|
|
24
23
|
DropdownMenuContent,
|
|
24
|
+
DropdownMenuRadioGroup,
|
|
25
|
+
DropdownMenuRadioItem,
|
|
25
26
|
DropdownMenuTrigger,
|
|
26
27
|
} from "../primitives/dropdown-menu";
|
|
27
28
|
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
|
|
@@ -33,6 +34,12 @@ export type WorkspaceSwitcherProps = {
|
|
|
33
34
|
readonly testId?: string;
|
|
34
35
|
};
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Requires an ancestor `SidebarProvider` (see `../ui/sidebar`) — this
|
|
39
|
+
* component renders `SidebarMenuButton`, which calls `useSidebar()`
|
|
40
|
+
* internally and throws ("useSidebar must be used within a
|
|
41
|
+
* SidebarProvider.") when rendered outside one.
|
|
42
|
+
*/
|
|
36
43
|
export function WorkspaceSwitcher({
|
|
37
44
|
workspaces,
|
|
38
45
|
activeId,
|
|
@@ -57,22 +64,28 @@ export function WorkspaceSwitcher({
|
|
|
57
64
|
<SidebarMenuItem>
|
|
58
65
|
<DropdownMenu>
|
|
59
66
|
<DropdownMenuTrigger asChild>
|
|
60
|
-
<SidebarMenuButton
|
|
61
|
-
|
|
67
|
+
<SidebarMenuButton
|
|
68
|
+
data-testid="workspace-switcher-trigger"
|
|
69
|
+
aria-label={t("kumiko.workspace.switch")}
|
|
70
|
+
>
|
|
71
|
+
<span className="truncate">
|
|
72
|
+
{active !== undefined ? labelOf(active) : t("kumiko.workspace.select")}
|
|
73
|
+
</span>
|
|
62
74
|
<ChevronsUpDown className="ml-auto" />
|
|
63
75
|
</SidebarMenuButton>
|
|
64
76
|
</DropdownMenuTrigger>
|
|
65
77
|
<DropdownMenuContent align="start" className="min-w-[10rem]">
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
<DropdownMenuRadioGroup value={activeId} onValueChange={onSelect}>
|
|
79
|
+
{workspaces.map((ws) => (
|
|
80
|
+
<DropdownMenuRadioItem
|
|
81
|
+
key={ws.definition.id}
|
|
82
|
+
value={ws.definition.id}
|
|
83
|
+
data-testid={`workspace-tab-${ws.definition.id}`}
|
|
84
|
+
>
|
|
85
|
+
<span className="truncate">{labelOf(ws)}</span>
|
|
86
|
+
</DropdownMenuRadioItem>
|
|
87
|
+
))}
|
|
88
|
+
</DropdownMenuRadioGroup>
|
|
76
89
|
</DropdownMenuContent>
|
|
77
90
|
</DropdownMenu>
|
|
78
91
|
</SidebarMenuItem>
|
|
@@ -49,7 +49,9 @@ describe("resizeImageBeforeUpload", () => {
|
|
|
49
49
|
globalThis.createImageBitmap = mock(async () => bitmap);
|
|
50
50
|
|
|
51
51
|
try {
|
|
52
|
-
|
|
52
|
+
// Content sized well above the 1-byte re-encoded blob so the resize
|
|
53
|
+
// wins the size-guard and the assertions below observe its output.
|
|
54
|
+
const file = new File(["x".repeat(2000)], "photo.jpg", { type: "image/jpeg" });
|
|
53
55
|
const result = await resizeImageBeforeUpload(file, 2560);
|
|
54
56
|
expect(capturedWidth).toBe(2560);
|
|
55
57
|
expect(capturedHeight).toBe(1280);
|
|
@@ -81,7 +83,9 @@ describe("resizeImageBeforeUpload", () => {
|
|
|
81
83
|
}));
|
|
82
84
|
|
|
83
85
|
try {
|
|
84
|
-
|
|
86
|
+
// Content sized well above the 1-byte re-encoded blob so the resize
|
|
87
|
+
// wins the size-guard and the assertions below observe its output.
|
|
88
|
+
const file = new File(["x".repeat(2000)], "photo.heic", { type: "image/heic" });
|
|
85
89
|
const result = await resizeImageBeforeUpload(file);
|
|
86
90
|
expect(result.name).toBe("photo.jpg");
|
|
87
91
|
expect(result.type).toBe("image/jpeg");
|
|
@@ -114,7 +118,9 @@ describe("resizeImageBeforeUpload", () => {
|
|
|
114
118
|
}));
|
|
115
119
|
|
|
116
120
|
try {
|
|
117
|
-
|
|
121
|
+
// Content sized well above the 1-byte re-encoded blob so the resize
|
|
122
|
+
// wins the size-guard and the assertions below observe its output.
|
|
123
|
+
const file = new File(["x".repeat(2000)], "photo.webp", { type: "image/webp" });
|
|
118
124
|
const result = await resizeImageBeforeUpload(file);
|
|
119
125
|
expect(result.name).toBe("photo.png");
|
|
120
126
|
expect(result.type).toBe("image/png");
|
|
@@ -126,6 +132,39 @@ describe("resizeImageBeforeUpload", () => {
|
|
|
126
132
|
}
|
|
127
133
|
});
|
|
128
134
|
|
|
135
|
+
test("belässt ein bereits optimiertes Bild unverändert, wenn der Re-Encode es vergrößern würde", async () => {
|
|
136
|
+
// A small in-spec PNG (e.g. palette-optimized) round-tripped through a
|
|
137
|
+
// canvas can come back as a bigger full-RGBA blob — the size guard must
|
|
138
|
+
// then keep serving the original bytes instead of the larger re-encode.
|
|
139
|
+
class FakeOffscreenCanvas {
|
|
140
|
+
getContext() {
|
|
141
|
+
return { drawImage: mock(() => {}) };
|
|
142
|
+
}
|
|
143
|
+
convertToBlob() {
|
|
144
|
+
return Promise.resolve(new Blob(["x".repeat(2000)], { type: "image/png" }));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// @ts-expect-error test stub for a browser-only API missing in jsdom
|
|
148
|
+
globalThis.OffscreenCanvas = FakeOffscreenCanvas;
|
|
149
|
+
globalThis.createImageBitmap = mock(async () => ({
|
|
150
|
+
close: mock(() => {}),
|
|
151
|
+
height: 100,
|
|
152
|
+
width: 100,
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const file = new File(["x"], "photo.png", { type: "image/png" });
|
|
157
|
+
const result = await resizeImageBeforeUpload(file);
|
|
158
|
+
expect(result).toBe(file);
|
|
159
|
+
expect(result.size).toBeLessThanOrEqual(file.size);
|
|
160
|
+
} finally {
|
|
161
|
+
// @ts-expect-error restore missing-API baseline
|
|
162
|
+
globalThis.OffscreenCanvas = undefined;
|
|
163
|
+
// @ts-expect-error restore missing-API baseline
|
|
164
|
+
globalThis.createImageBitmap = undefined;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
129
168
|
test("fällt bei Decode-Fehlern auf die Originaldatei zurück", async () => {
|
|
130
169
|
class FakeOffscreenCanvas {
|
|
131
170
|
getContext() {
|
package/src/lib/clamp.ts
ADDED
package/src/lib/resize-image.ts
CHANGED
|
@@ -54,7 +54,12 @@ export async function resizeImageBeforeUpload(
|
|
|
54
54
|
const actualType = blob.type || file.type;
|
|
55
55
|
const fileName =
|
|
56
56
|
actualType === file.type ? file.name : withMatchingExtension(file.name, actualType);
|
|
57
|
-
|
|
57
|
+
const resized = new File([blob], fileName, { type: actualType });
|
|
58
|
+
// Only takes the re-encode if it actually shrank the file — a
|
|
59
|
+
// palette-optimized PNG or a small in-spec photo can come back larger
|
|
60
|
+
// after a full RGBA canvas round-trip. EXIF/GPS stripping is then
|
|
61
|
+
// best-effort: an original that's kept as-is keeps its metadata too.
|
|
62
|
+
return resized.size < file.size ? resized : file;
|
|
58
63
|
} catch {
|
|
59
64
|
return file;
|
|
60
65
|
}
|
|
@@ -183,13 +183,25 @@ describe("defaultCellRender", () => {
|
|
|
183
183
|
expect(result).not.toMatch(/[.,]\d\d$/);
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
-
test("money → rehydrated { amount major, amountMinor }
|
|
187
|
-
// rehydrateMoney (fw#1830): amount is major units, amountMinor is
|
|
188
|
-
//
|
|
186
|
+
test("money → rehydrated { amount major, amountMinor } derives minor units from amount + currencyDecimals", () => {
|
|
187
|
+
// rehydrateMoney (fw#1830): amount is major units, amountMinor is a flat
|
|
188
|
+
// ×100 scale that disagrees with currencyDecimals for non-2-decimal
|
|
189
|
+
// currencies — defaultCellRender ignores amountMinor and derives minor
|
|
190
|
+
// units from `amount` instead (see index.tsx).
|
|
189
191
|
const result = defaultCellRender({ amount: 119, currency: "EUR", amountMinor: 11900 }, "money");
|
|
190
192
|
expect(result.replace(/[^0-9]/g, "")).toBe("11900");
|
|
191
193
|
});
|
|
192
194
|
|
|
195
|
+
test("money → zero-decimal currency (JPY) ignores amountMinor's mismatched flat scale", () => {
|
|
196
|
+
const result = defaultCellRender({ amount: 500, currency: "JPY", amountMinor: 50000 }, "money");
|
|
197
|
+
expect(result.replace(/[^0-9]/g, "")).toBe("500");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("money → three-decimal currency (BHD) ignores amountMinor's mismatched flat scale", () => {
|
|
201
|
+
const result = defaultCellRender({ amount: 1.5, currency: "BHD", amountMinor: 150 }, "money");
|
|
202
|
+
expect(result.replace(/[^0-9]/g, "")).toBe("1500");
|
|
203
|
+
});
|
|
204
|
+
|
|
193
205
|
test("money → unerwarteter Value-Shape fällt auf String zurück", () => {
|
|
194
206
|
expect(defaultCellRender(45000, "money")).toBe("45000");
|
|
195
207
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// DateField rendering tests — happy-dom + @testing-library/react. Pins
|
|
2
|
+
// that the input's `placeholder` actually comes from `t()` + resolvedLocale
|
|
3
|
+
// (date-parse.test.ts only covers the pure formatDatePlaceholder logic; a
|
|
4
|
+
// wrong/renamed i18n key or a missing LocaleProvider fallback would stay
|
|
5
|
+
// green there).
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
createStaticLocaleResolver,
|
|
10
|
+
kumikoDefaultTranslations,
|
|
11
|
+
LocaleProvider,
|
|
12
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
13
|
+
import { render, screen } from "@testing-library/react";
|
|
14
|
+
import { DateField } from "../date-field";
|
|
15
|
+
|
|
16
|
+
function renderWithLocale(locale: string) {
|
|
17
|
+
return render(
|
|
18
|
+
<LocaleProvider
|
|
19
|
+
resolver={createStaticLocaleResolver({ locale })}
|
|
20
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
21
|
+
>
|
|
22
|
+
<DateField id="date" name="date" value="" onChange={() => {}} locale={locale} />
|
|
23
|
+
</LocaleProvider>,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("DateField placeholder", () => {
|
|
28
|
+
test("de-DE → placeholder built from t() keys, not a hardcoded pattern", () => {
|
|
29
|
+
renderWithLocale("de-DE");
|
|
30
|
+
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("TT.MM.JJJJ");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("en-US → placeholder built from t() keys, not a hardcoded pattern", () => {
|
|
34
|
+
renderWithLocale("en-US");
|
|
35
|
+
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("MM/DD/YYYY");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// rejection.
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { kumikoDefaultTranslations } from "@cosmicdrift/kumiko-renderer";
|
|
8
9
|
import { Temporal } from "temporal-polyfill";
|
|
9
10
|
import {
|
|
10
11
|
formatDateForInput,
|
|
@@ -14,8 +15,19 @@ import {
|
|
|
14
15
|
toIso,
|
|
15
16
|
} from "../date-parse";
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// Derived from kumikoDefaultTranslations instead of hardcoded so a drift in
|
|
19
|
+
// the i18n defaults (e.g. renaming "T" → "D") fails this test too.
|
|
20
|
+
function placeholderLetters(locale: "de" | "en"): { year: string; month: string; day: string } {
|
|
21
|
+
const bundle = kumikoDefaultTranslations[locale];
|
|
22
|
+
return {
|
|
23
|
+
year: bundle?.["kumiko.field.dateField.placeholderYear"] ?? "",
|
|
24
|
+
month: bundle?.["kumiko.field.dateField.placeholderMonth"] ?? "",
|
|
25
|
+
day: bundle?.["kumiko.field.dateField.placeholderDay"] ?? "",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const dePlaceholderLetters = placeholderLetters("de");
|
|
30
|
+
const enPlaceholderLetters = placeholderLetters("en");
|
|
19
31
|
|
|
20
32
|
describe("parseIso", () => {
|
|
21
33
|
test("valid yyyy-mm-dd → PlainDate (no TZ conversion)", () => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// test interacts with the rendered DOM (click/type/paste) and asserts on
|
|
3
3
|
// the outcome; none merely check that the component mounts.
|
|
4
4
|
|
|
5
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
5
|
+
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
6
6
|
import type { FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
7
7
|
import type { EmbeddedListColumn, EmbeddedListInputProps } from "@cosmicdrift/kumiko-renderer";
|
|
8
8
|
import {
|
|
@@ -11,9 +11,15 @@ import {
|
|
|
11
11
|
LocaleProvider,
|
|
12
12
|
} from "@cosmicdrift/kumiko-renderer";
|
|
13
13
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
14
|
-
import { type ReactElement, useState } from "react";
|
|
14
|
+
import { act, type ReactElement, useState } from "react";
|
|
15
15
|
import { EmbeddedListInput } from "../embedded-list-input";
|
|
16
16
|
|
|
17
|
+
function setViewportWidth(width: number): void {
|
|
18
|
+
(
|
|
19
|
+
window as unknown as { happyDOM: { setInnerWidth: (n: number) => void } }
|
|
20
|
+
).happyDOM.setInnerWidth(width);
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
function renderWithLocale(ui: ReactElement) {
|
|
18
24
|
return render(
|
|
19
25
|
<LocaleProvider
|
|
@@ -118,12 +124,53 @@ describe("EmbeddedListInput — desktop/mobile are mutually exclusive mounts (#1
|
|
|
118
124
|
});
|
|
119
125
|
|
|
120
126
|
test("mobile viewport mounts only the card layout, not the table", () => {
|
|
127
|
+
const originalWidth = window.innerWidth;
|
|
128
|
+
setViewportWidth(500);
|
|
129
|
+
try {
|
|
130
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
|
|
131
|
+
expect(screen.getByTestId("lines-mobile")).toBeTruthy();
|
|
132
|
+
expect(screen.queryByTestId("lines-desktop")).toBeNull();
|
|
133
|
+
expect(document.querySelectorAll('[data-cell-id="lines-0-amount"]').length).toBe(1);
|
|
134
|
+
} finally {
|
|
135
|
+
setViewportWidth(originalWidth);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("mobile viewport at mount never creates the desktop table — no mount-then-discard flicker (fw#1868)", () => {
|
|
140
|
+
// The old useIsMobile hook only set its value in a useEffect, so the
|
|
141
|
+
// very first render always mounted the desktop table (isMobile=false)
|
|
142
|
+
// before an effect swapped in the mobile cards — even on a phone. Since
|
|
143
|
+
// effects flush synchronously inside render()'s act(), a plain post-
|
|
144
|
+
// render DOM assertion can't tell the two implementations apart (both
|
|
145
|
+
// end up correct); spying on document.createElement across the whole
|
|
146
|
+
// render call is the only way to prove a <table> was never built at all.
|
|
121
147
|
const originalWidth = window.innerWidth;
|
|
122
148
|
window.innerWidth = 500;
|
|
149
|
+
const createElementSpy = spyOn(document, "createElement");
|
|
123
150
|
try {
|
|
124
151
|
renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
|
|
125
152
|
expect(screen.getByTestId("lines-mobile")).toBeTruthy();
|
|
126
153
|
expect(screen.queryByTestId("lines-desktop")).toBeNull();
|
|
154
|
+
expect(createElementSpy.mock.calls.some(([tag]) => tag === "table")).toBe(false);
|
|
155
|
+
} finally {
|
|
156
|
+
window.innerWidth = originalWidth;
|
|
157
|
+
createElementSpy.mockRestore();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("resizing past the breakpoint after mount swaps the table for the card layout", async () => {
|
|
162
|
+
const originalWidth = window.innerWidth;
|
|
163
|
+
try {
|
|
164
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
|
|
165
|
+
expect(screen.getByTestId("lines-desktop")).toBeTruthy();
|
|
166
|
+
|
|
167
|
+
await act(async () => {
|
|
168
|
+
window.innerWidth = 500;
|
|
169
|
+
window.dispatchEvent(new Event("resize"));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(screen.queryByTestId("lines-desktop")).toBeNull();
|
|
173
|
+
expect(screen.getByTestId("lines-mobile")).toBeTruthy();
|
|
127
174
|
expect(document.querySelectorAll('[data-cell-id="lines-0-amount"]').length).toBe(1);
|
|
128
175
|
} finally {
|
|
129
176
|
window.innerWidth = originalWidth;
|
|
@@ -626,6 +673,34 @@ describe("EmbeddedListInput — currency (#1839)", () => {
|
|
|
626
673
|
});
|
|
627
674
|
});
|
|
628
675
|
|
|
676
|
+
describe("EmbeddedListInput — totals follow the app locale, not the browser locale", () => {
|
|
677
|
+
test("a de-DE app locale formats both the money total and a plain number total with '.' thousands / ',' decimal", () => {
|
|
678
|
+
const rows = [{ description: "A", quantity: 1234.5, amount: 150099 }];
|
|
679
|
+
render(
|
|
680
|
+
<LocaleProvider
|
|
681
|
+
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
682
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
683
|
+
>
|
|
684
|
+
<EmbeddedListInput
|
|
685
|
+
{...baseProps({
|
|
686
|
+
rows,
|
|
687
|
+
currency: "EUR",
|
|
688
|
+
totals: [
|
|
689
|
+
{ field: "amount", label: "Sum", value: 150099 },
|
|
690
|
+
{ field: "quantity", label: "Qty total", value: 1234.5 },
|
|
691
|
+
],
|
|
692
|
+
})}
|
|
693
|
+
/>
|
|
694
|
+
</LocaleProvider>,
|
|
695
|
+
);
|
|
696
|
+
const totals = within(screen.getByTestId("lines-desktop")).getByTestId("lines-totals");
|
|
697
|
+
// de-DE: money "1.500,99 €", plain number "1.234,5" — both use "." as
|
|
698
|
+
// the thousands separator and "," as the decimal separator.
|
|
699
|
+
expect(totals.textContent).toContain("1.500,99");
|
|
700
|
+
expect(totals.textContent).toContain("1.234,5");
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
629
704
|
describe("EmbeddedListInput — desktop table width (solon#107)", () => {
|
|
630
705
|
test("the table keeps min-w-max so columns don't shrink below their width classes", () => {
|
|
631
706
|
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
@@ -23,3 +23,27 @@ describe("FileUploadInput — image variant preview src (#1950)", () => {
|
|
|
23
23
|
expect(container.querySelector("img")?.getAttribute("src")).toBe("/api/files/abc");
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
describe("FileUploadInput — camera capture (#1953)", () => {
|
|
28
|
+
test("with capture set, the <input type=file> carries the capture attribute", () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<FileUploadInput
|
|
31
|
+
kind="image"
|
|
32
|
+
id="avatar"
|
|
33
|
+
value={null}
|
|
34
|
+
onChange={noop}
|
|
35
|
+
capture="environment"
|
|
36
|
+
/>,
|
|
37
|
+
);
|
|
38
|
+
expect(container.querySelector('input[type="file"]')?.getAttribute("capture")).toBe(
|
|
39
|
+
"environment",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("without capture, the <input type=file> has no capture attribute", () => {
|
|
44
|
+
const { container } = render(
|
|
45
|
+
<FileUploadInput kind="image" id="avatar" value={null} onChange={noop} />,
|
|
46
|
+
);
|
|
47
|
+
expect(container.querySelector('input[type="file"]')?.hasAttribute("capture")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -105,6 +105,14 @@ export function DateField({
|
|
|
105
105
|
disabled={disabled}
|
|
106
106
|
required={required}
|
|
107
107
|
aria-invalid={hasError === true ? true : undefined}
|
|
108
|
+
// Deliberate: slot order/separator follow resolvedLocale (the
|
|
109
|
+
// `locale` prop, or the browser locale), but the letters follow
|
|
110
|
+
// the app's UI locale via t() — a caller rendering a date in a
|
|
111
|
+
// locale that differs from the UI locale gets a mixed-locale
|
|
112
|
+
// placeholder (e.g. German UI + locale="en-US" → "MM/TT/JJJJ").
|
|
113
|
+
// Accepted for now (fw#1879): a static per-locale letter lookup
|
|
114
|
+
// would need its own locale-root resolution and tests for a
|
|
115
|
+
// placeholder-only, Low-severity mismatch.
|
|
108
116
|
placeholder={formatDatePlaceholder(resolvedLocale, {
|
|
109
117
|
year: t("kumiko.field.dateField.placeholderYear"),
|
|
110
118
|
month: t("kumiko.field.dateField.placeholderMonth"),
|
|
@@ -80,7 +80,12 @@ function localeDateParts(locale: string): Intl.DateTimeFormatPart[] {
|
|
|
80
80
|
const refEpochMillis = activeTemporal()
|
|
81
81
|
.PlainDate.from({ year: 2026, month: 1, day: 2 })
|
|
82
82
|
.toZonedDateTime("UTC").epochMilliseconds;
|
|
83
|
-
return new Intl.DateTimeFormat(locale, {
|
|
83
|
+
return new Intl.DateTimeFormat(locale, {
|
|
84
|
+
timeZone: "UTC",
|
|
85
|
+
year: "numeric",
|
|
86
|
+
month: "2-digit",
|
|
87
|
+
day: "2-digit",
|
|
88
|
+
}).formatToParts(refEpochMillis);
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
// Field order of the numeric locale format. de → [d,m,y], en-US →
|
|
@@ -83,6 +83,29 @@ export function DropdownMenuCheckboxItem({
|
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
export const DropdownMenuRadioGroup = Primitive.RadioGroup;
|
|
87
|
+
|
|
88
|
+
// RadioItem with a dot indicator on the left — for exclusive single-choice
|
|
89
|
+
// selection (e.g. WorkspaceSwitcher). Unlike CheckboxItem, Radix renders
|
|
90
|
+
// role="menuitemradio" + aria-checked here instead of role="menuitemcheckbox",
|
|
91
|
+
// which announces "one of several" to screen readers instead of "on/off".
|
|
92
|
+
export function DropdownMenuRadioItem({
|
|
93
|
+
className,
|
|
94
|
+
children,
|
|
95
|
+
...props
|
|
96
|
+
}: ComponentPropsWithoutRef<typeof Primitive.RadioItem>): ReactNode {
|
|
97
|
+
return (
|
|
98
|
+
<Primitive.RadioItem className={cn(itemClass, "pl-8 pr-2", className)} {...props}>
|
|
99
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
100
|
+
<Primitive.ItemIndicator>
|
|
101
|
+
<Check className="h-3.5 w-3.5" />
|
|
102
|
+
</Primitive.ItemIndicator>
|
|
103
|
+
</span>
|
|
104
|
+
{children}
|
|
105
|
+
</Primitive.RadioItem>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
86
109
|
export function DropdownMenuLabel({
|
|
87
110
|
className,
|
|
88
111
|
...props
|