@cosmicdrift/kumiko-renderer-web 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/action-menu.test.tsx +75 -0
- package/src/__tests__/breadcrumb.test.tsx +50 -0
- package/src/__tests__/create-app.test.tsx +171 -1
- package/src/__tests__/dashboard-screen.test.tsx +393 -0
- package/src/__tests__/default-app-shell.test.tsx +47 -1
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/kumiko-screen.test.tsx +155 -1
- package/src/__tests__/lightbox.test.tsx +46 -0
- package/src/__tests__/nav-tree.test.tsx +122 -0
- package/src/__tests__/primitives.test.tsx +32 -1
- package/src/__tests__/profile-menu.test.tsx +29 -0
- package/src/__tests__/projection-detail.test.tsx +101 -0
- package/src/__tests__/projection-list-actions.test.tsx +131 -0
- package/src/__tests__/render-list.test.tsx +11 -3
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/toast.test.tsx +9 -9
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/__tests__/workspace-shell.test.tsx +102 -47
- package/src/__tests__/xss-safety.test.tsx +62 -0
- package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
- package/src/app/browser-locale.ts +1 -1
- package/src/app/create-app.tsx +96 -29
- package/src/app/dashboard-body.tsx +454 -0
- package/src/index.ts +75 -1
- package/src/layout/__tests__/nav-registry.test.ts +105 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
- package/src/layout/default-app-shell.tsx +9 -71
- package/src/layout/nav-tree.tsx +167 -37
- package/src/layout/shell-breadcrumb.ts +46 -0
- package/src/layout/shell-header.tsx +114 -0
- package/src/layout/workspace-shell.tsx +45 -18
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/input-testid.test.tsx +45 -0
- package/src/primitives/__tests__/money-input.test.tsx +13 -1
- package/src/primitives/dialog.tsx +47 -76
- package/src/primitives/index.tsx +215 -37
- package/src/primitives/layout.tsx +39 -0
- package/src/primitives/lightbox.tsx +39 -0
- package/src/primitives/modal-shell.tsx +62 -0
- package/src/primitives/money-input.tsx +20 -10
- package/src/primitives/toast.tsx +14 -9
- package/src/sse/live-events.ts +6 -1
- package/src/styles.css +25 -0
- package/src/tokens.ts +4 -0
- package/src/ui/__tests__/avatar.test.tsx +66 -0
- package/src/ui/__tests__/sheet.test.tsx +102 -0
- package/src/version/__tests__/update-checker.test.tsx +115 -0
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/drawer.test.tsx +35 -0
- package/src/widgets/__tests__/form-kit.test.tsx +368 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
- package/src/widgets/__tests__/query-table.test.tsx +118 -0
- package/src/widgets/__tests__/result-panel.test.tsx +117 -0
- package/src/widgets/__tests__/widgets.test.tsx +243 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/charts.tsx +228 -0
- package/src/widgets/collapsible-section.tsx +42 -0
- package/src/widgets/detail-list.tsx +43 -0
- package/src/widgets/drawer.tsx +49 -0
- package/src/widgets/feed-list.tsx +35 -0
- package/src/widgets/form-fields.tsx +326 -0
- package/src/widgets/index.ts +59 -0
- package/src/widgets/infinity-list.tsx +112 -0
- package/src/widgets/mode-switch.tsx +41 -0
- package/src/widgets/progress-bar.tsx +27 -0
- package/src/widgets/progress-list.tsx +38 -0
- package/src/widgets/query-table.tsx +103 -0
- package/src/widgets/result-panel.tsx +251 -0
- package/src/widgets/section-card.tsx +30 -0
- package/src/widgets/stat.tsx +181 -0
- package/src/widgets/states.tsx +89 -0
- package/src/widgets/status-badge.tsx +51 -0
- package/src/widgets/use-draft.ts +40 -0
- package/src/ui/card.tsx +0 -93
- package/src/ui/collapsible.tsx +0 -34
- package/src/ui/dropdown-menu.tsx +0 -258
- package/src/ui/select.tsx +0 -191
- /package/src/__tests__/{download.test.ts → download.test.tsx} +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// ShellHeader — die Inset-Kopfzeile für Sidebar-basierte Shells: SidebarTrigger
|
|
2
|
+
// (Rail-/Mobile-Sheet-Toggle) + Breadcrumb mit dem aktiven Screen + optionale
|
|
3
|
+
// rechtsbündige headerActions. Geteilt von DefaultAppShell und WorkspaceShell,
|
|
4
|
+
// damit beide dieselbe Kopfzeile tragen (Höhe h-16, kollabiert auf h-12 mit
|
|
5
|
+
// der Icon-Rail).
|
|
6
|
+
|
|
7
|
+
import type { NavNode } from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
|
|
9
|
+
import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
10
|
+
import { toAppSchema, useNav, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
11
|
+
import { type ReactNode, useMemo } from "react";
|
|
12
|
+
import {
|
|
13
|
+
Breadcrumb,
|
|
14
|
+
BreadcrumbItem,
|
|
15
|
+
BreadcrumbLink,
|
|
16
|
+
BreadcrumbList,
|
|
17
|
+
BreadcrumbPage,
|
|
18
|
+
BreadcrumbSeparator,
|
|
19
|
+
} from "../ui/breadcrumb";
|
|
20
|
+
import { Separator } from "../ui/separator";
|
|
21
|
+
import { SidebarTrigger } from "../ui/sidebar";
|
|
22
|
+
import { buildNavRegistrySliceForApp, lastSegment } from "./nav-tree";
|
|
23
|
+
import { type BreadcrumbCrumb, resolveDetailBreadcrumb } from "./shell-breadcrumb";
|
|
24
|
+
|
|
25
|
+
type ShellHeaderUser = {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly roles: readonly string[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function ShellHeader({
|
|
31
|
+
schema,
|
|
32
|
+
user,
|
|
33
|
+
headerActions,
|
|
34
|
+
}: {
|
|
35
|
+
readonly schema: AppSchema | FeatureSchema;
|
|
36
|
+
readonly user?: ShellHeaderUser;
|
|
37
|
+
readonly headerActions?: ReactNode;
|
|
38
|
+
}): ReactNode {
|
|
39
|
+
const nav = useNav();
|
|
40
|
+
const t = useTranslation();
|
|
41
|
+
const appSchema = toAppSchema(schema);
|
|
42
|
+
const tree = useMemo(() => {
|
|
43
|
+
const source = buildNavRegistrySliceForApp(appSchema);
|
|
44
|
+
return resolveNavigation({ source, ...(user !== undefined && { user }) });
|
|
45
|
+
}, [appSchema, user]);
|
|
46
|
+
|
|
47
|
+
const allScreens = useMemo(
|
|
48
|
+
() => appSchema.features.flatMap((f) => f.screens),
|
|
49
|
+
[appSchema.features],
|
|
50
|
+
);
|
|
51
|
+
const screenId = nav.route?.screenId;
|
|
52
|
+
const crumbs = useMemo((): readonly BreadcrumbCrumb[] | undefined => {
|
|
53
|
+
if (screenId === undefined) return undefined;
|
|
54
|
+
const navLabel = activeNavLabel(tree, screenId, (k) => t(k));
|
|
55
|
+
if (navLabel !== undefined) return [{ label: navLabel }];
|
|
56
|
+
return resolveDetailBreadcrumb(allScreens, screenId, t);
|
|
57
|
+
}, [allScreens, screenId, t, tree]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
|
61
|
+
<div className="flex items-center gap-2 px-4">
|
|
62
|
+
<SidebarTrigger className="-ml-1" />
|
|
63
|
+
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
|
64
|
+
{crumbs !== undefined && crumbs.length > 0 && (
|
|
65
|
+
<Breadcrumb>
|
|
66
|
+
<BreadcrumbList>
|
|
67
|
+
{crumbs.map((crumb, index) => {
|
|
68
|
+
const screenId = crumb.screenId;
|
|
69
|
+
return (
|
|
70
|
+
<BreadcrumbItem key={screenId ?? crumb.label}>
|
|
71
|
+
{index > 0 && <BreadcrumbSeparator />}
|
|
72
|
+
{screenId !== undefined && index < crumbs.length - 1 ? (
|
|
73
|
+
<BreadcrumbLink
|
|
74
|
+
href="#"
|
|
75
|
+
onClick={(e) => {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
nav.navigate({ screenId });
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{crumb.label}
|
|
81
|
+
</BreadcrumbLink>
|
|
82
|
+
) : (
|
|
83
|
+
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
|
84
|
+
)}
|
|
85
|
+
</BreadcrumbItem>
|
|
86
|
+
);
|
|
87
|
+
})}
|
|
88
|
+
</BreadcrumbList>
|
|
89
|
+
</Breadcrumb>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
{headerActions !== undefined && (
|
|
93
|
+
<div data-kumiko-layout="header-actions" className="ml-auto flex items-center gap-2 px-4">
|
|
94
|
+
{headerActions}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</header>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function activeNavLabel(
|
|
102
|
+
nodes: readonly NavNode[],
|
|
103
|
+
screenId: string,
|
|
104
|
+
t: (key: string) => string,
|
|
105
|
+
): string | undefined {
|
|
106
|
+
for (const node of nodes) {
|
|
107
|
+
if (node.screen !== undefined && lastSegment(node.screen) === screenId) {
|
|
108
|
+
return node.label.includes(".") || node.label.includes(":") ? t(node.label) : node.label;
|
|
109
|
+
}
|
|
110
|
+
const child = activeNavLabel(node.children, screenId, t);
|
|
111
|
+
if (child !== undefined) return child;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
@@ -31,16 +31,22 @@
|
|
|
31
31
|
|
|
32
32
|
import type { AccessRule } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
33
33
|
import type { AppSchema, FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
|
|
34
|
-
import { qualifyNavId, toAppSchema, useNav } from "@cosmicdrift/kumiko-renderer";
|
|
34
|
+
import { qualifyNavId, toAppSchema, UserRolesProvider, useNav } from "@cosmicdrift/kumiko-renderer";
|
|
35
35
|
import { type ReactNode, useCallback, useLayoutEffect, useMemo } from "react";
|
|
36
36
|
import { useResolvers } from "../app/resolvers-context";
|
|
37
|
-
import {
|
|
38
|
-
|
|
37
|
+
import {
|
|
38
|
+
Sidebar,
|
|
39
|
+
SidebarContent,
|
|
40
|
+
SidebarFooter,
|
|
41
|
+
SidebarHeader,
|
|
42
|
+
SidebarInset,
|
|
43
|
+
SidebarProvider,
|
|
44
|
+
SidebarRail,
|
|
45
|
+
} from "../ui/sidebar";
|
|
39
46
|
import { EditorPanel } from "./editor-panel";
|
|
40
47
|
import { lastSegment, NavTree } from "./nav-tree";
|
|
41
|
-
import {
|
|
48
|
+
import { ShellHeader } from "./shell-header";
|
|
42
49
|
import { parseTargetFromSearchParams } from "./target-url";
|
|
43
|
-
import { Topbar } from "./topbar";
|
|
44
50
|
import { WorkspaceSwitcher } from "./workspace-switcher";
|
|
45
51
|
|
|
46
52
|
export type WorkspaceShellUser = {
|
|
@@ -69,6 +75,8 @@ export type WorkspaceShellProps = {
|
|
|
69
75
|
* Info. Klebt am unteren Rand via `mt-auto` (siehe Sidebar.footer).
|
|
70
76
|
* Symmetrisch zu DefaultAppShell.sidebarFooter. */
|
|
71
77
|
readonly sidebarFooter?: ReactNode;
|
|
78
|
+
/** Runtime badge slot per nav leaf (bare nav id), same as DefaultAppShell. */
|
|
79
|
+
readonly navBadges?: ReadonlyMap<string, ReactNode>;
|
|
72
80
|
/** Screen content. */
|
|
73
81
|
readonly children: ReactNode;
|
|
74
82
|
};
|
|
@@ -80,6 +88,7 @@ export function WorkspaceShell({
|
|
|
80
88
|
user,
|
|
81
89
|
initialWorkspaceId,
|
|
82
90
|
sidebarFooter,
|
|
91
|
+
navBadges,
|
|
83
92
|
children,
|
|
84
93
|
}: WorkspaceShellProps): ReactNode {
|
|
85
94
|
const app = useMemo(() => toAppSchema(schema), [schema]);
|
|
@@ -201,24 +210,42 @@ export function WorkspaceShell({
|
|
|
201
210
|
schema={app}
|
|
202
211
|
{...(user !== undefined && { user })}
|
|
203
212
|
{...(allowedNavQns !== undefined && { allowedNavQns })}
|
|
213
|
+
{...(navBadges !== undefined && { navBadges })}
|
|
204
214
|
/>
|
|
205
215
|
);
|
|
206
216
|
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// (
|
|
217
|
+
// Modern shell (wie DefaultAppShell): collapsible Icon-Rail-Sidebar trägt
|
|
218
|
+
// Brand + Workspace-Switcher + Nav + Footer; der SidebarInset rendert die
|
|
219
|
+
// geteilte ShellHeader (Panel-Toggle + Breadcrumb + Actions) über dem
|
|
220
|
+
// Content. topbarActions landen rechts in der Header-Zeile statt in einer
|
|
221
|
+
// separaten Topbar — eine Kopfzeile statt zwei.
|
|
210
222
|
return (
|
|
211
223
|
<SidebarProvider>
|
|
212
|
-
<
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
224
|
+
<Sidebar collapsible="icon">
|
|
225
|
+
<SidebarHeader data-kumiko-layout="sidebar-header">
|
|
226
|
+
{brand}
|
|
227
|
+
{switcher}
|
|
228
|
+
</SidebarHeader>
|
|
229
|
+
<SidebarContent data-kumiko-layout="sidebar-nav">{sidebarContent}</SidebarContent>
|
|
230
|
+
{sidebarFooter !== undefined && (
|
|
231
|
+
<SidebarFooter data-kumiko-layout="sidebar-footer">{sidebarFooter}</SidebarFooter>
|
|
232
|
+
)}
|
|
233
|
+
<SidebarRail />
|
|
234
|
+
</Sidebar>
|
|
235
|
+
<SidebarInset>
|
|
236
|
+
<ShellHeader
|
|
237
|
+
schema={app}
|
|
238
|
+
{...(user !== undefined && { user })}
|
|
239
|
+
{...(topbarActions !== undefined && { headerActions: topbarActions })}
|
|
240
|
+
/>
|
|
241
|
+
<main className="flex-1 overflow-auto">
|
|
242
|
+
{activeTarget !== undefined ? (
|
|
243
|
+
<EditorPanel resolvers={resolvers} />
|
|
244
|
+
) : (
|
|
245
|
+
<UserRolesProvider roles={user?.roles}>{children}</UserRolesProvider>
|
|
246
|
+
)}
|
|
247
|
+
</main>
|
|
248
|
+
</SidebarInset>
|
|
222
249
|
</SidebarProvider>
|
|
223
250
|
);
|
|
224
251
|
}
|
|
@@ -164,4 +164,31 @@ describe("defaultCellRender", () => {
|
|
|
164
164
|
expect(defaultCellRender("hallo", "text")).toBe("hallo");
|
|
165
165
|
expect(defaultCellRender(42, "number")).toBe("42");
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
test("money → { amount, currency } formatiert, kein [object Object]", () => {
|
|
169
|
+
const result = defaultCellRender({ amount: 45000, currency: "EUR" }, "money");
|
|
170
|
+
expect(result).not.toBe("[object Object]");
|
|
171
|
+
expect(result.replace(/[^0-9]/g, "")).toBe("45000");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("money → JPY ohne Nachkommastellen (currencyDecimals)", () => {
|
|
175
|
+
const result = defaultCellRender({ amount: 1000, currency: "JPY" }, "money");
|
|
176
|
+
const expected = new Intl.NumberFormat(undefined, {
|
|
177
|
+
style: "currency",
|
|
178
|
+
currency: "JPY",
|
|
179
|
+
minimumFractionDigits: 0,
|
|
180
|
+
maximumFractionDigits: 0,
|
|
181
|
+
}).format(1000);
|
|
182
|
+
expect(result).toBe(expected);
|
|
183
|
+
expect(result).not.toMatch(/[.,]\d\d$/);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("money → unerwarteter Value-Shape fällt auf String zurück", () => {
|
|
187
|
+
expect(defaultCellRender(45000, "money")).toBe("45000");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("money → invalid currency does not throw, falls back to [object Object] like pre-regression (#1494)", () => {
|
|
191
|
+
expect(() => defaultCellRender({ amount: 1, currency: "" }, "money")).not.toThrow();
|
|
192
|
+
expect(defaultCellRender({ amount: 1, currency: "" }, "money")).toBe("[object Object]");
|
|
193
|
+
});
|
|
167
194
|
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// #950 — Input forwardet testId (→ data-testid) + readOnly ans echte <input>,
|
|
2
|
+
// damit App-Tests getByTestId(...) + .readOnly/.disabled assertieren können.
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { render, screen } from "@testing-library/react";
|
|
5
|
+
import { defaultPrimitives } from "../index";
|
|
6
|
+
|
|
7
|
+
const { Input } = defaultPrimitives;
|
|
8
|
+
const noop = () => {};
|
|
9
|
+
|
|
10
|
+
describe("DefaultInput testId/readOnly forwarding (#950)", () => {
|
|
11
|
+
test("text: testId → data-testid, readOnly durchgereicht", () => {
|
|
12
|
+
render(
|
|
13
|
+
<Input
|
|
14
|
+
kind="text"
|
|
15
|
+
id="subdomain"
|
|
16
|
+
name="subdomain"
|
|
17
|
+
value="acme"
|
|
18
|
+
onChange={noop}
|
|
19
|
+
testId="url-settings-subdomain"
|
|
20
|
+
readOnly
|
|
21
|
+
/>,
|
|
22
|
+
);
|
|
23
|
+
const input = screen.getByTestId("url-settings-subdomain");
|
|
24
|
+
expect(input.tagName).toBe("INPUT");
|
|
25
|
+
expect((input as HTMLInputElement).readOnly).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("readOnly default false (nicht gesetzt = editierbar)", () => {
|
|
29
|
+
render(<Input kind="text" id="x" name="x" value="" onChange={noop} testId="plain" />);
|
|
30
|
+
expect((screen.getByTestId("plain") as HTMLInputElement).readOnly).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("email/password/number: testId erreicht das Element", () => {
|
|
34
|
+
render(
|
|
35
|
+
<>
|
|
36
|
+
<Input kind="email" id="e" name="e" value="" onChange={noop} testId="tid-email" />
|
|
37
|
+
<Input kind="password" id="p" name="p" value="" onChange={noop} testId="tid-pw" />
|
|
38
|
+
<Input kind="number" id="n" name="n" value={1} onChange={noop} testId="tid-num" />
|
|
39
|
+
</>,
|
|
40
|
+
);
|
|
41
|
+
for (const tid of ["tid-email", "tid-pw", "tid-num"]) {
|
|
42
|
+
expect(screen.getByTestId(tid).tagName).toBe("INPUT");
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -8,7 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, mock, test } from "bun:test";
|
|
10
10
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
11
|
-
import { currencyDecimals, MoneyInput, parseLocaleNumber } from "../money-input";
|
|
11
|
+
import { currencyDecimals, formatMoney, MoneyInput, parseLocaleNumber } from "../money-input";
|
|
12
|
+
|
|
13
|
+
describe("formatMoney", () => {
|
|
14
|
+
test("formats a normal amount with the currency symbol", () => {
|
|
15
|
+
expect(formatMoney(199_99, "USD", "en-US")).toBe("$199.99");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("falls back to the raw amount instead of throwing on an invalid currency (#1494)", () => {
|
|
19
|
+
expect(formatMoney(100, "", "en-US")).toBe("100");
|
|
20
|
+
expect(formatMoney(100, "EU", "en-US")).toBe("100");
|
|
21
|
+
expect(formatMoney(100, "€", "en-US")).toBe("100");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
12
24
|
|
|
13
25
|
describe("currencyDecimals", () => {
|
|
14
26
|
test("0-Decimal-Währungen (JPY/KRW/VND/ISK)", () => {
|
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// secondary. Async-onConfirm wird über loading-State gerendert
|
|
4
|
-
// (Spinner im Confirm-Button bis der Promise resolved).
|
|
5
|
-
//
|
|
6
|
-
// Ausgelagert von primitives/index.tsx weil das Radix-Setup mehrere
|
|
7
|
-
// Dependencies und Sub-Exports zieht — hält das Primitives-Hauptfile
|
|
8
|
-
// schlank.
|
|
1
|
+
// Confirm dialog built on ModalShell. Cancel is always secondary; async
|
|
2
|
+
// onConfirm shows a spinner on the confirm button until the promise settles.
|
|
9
3
|
|
|
10
4
|
import type { DialogProps } from "@cosmicdrift/kumiko-renderer";
|
|
11
5
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
12
6
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
13
|
-
import { Loader2
|
|
7
|
+
import { Loader2 } from "lucide-react";
|
|
14
8
|
import { type ReactNode, useState } from "react";
|
|
15
9
|
import { cn } from "../lib/cn";
|
|
10
|
+
import { ModalShell } from "./modal-shell";
|
|
16
11
|
|
|
17
12
|
export function DefaultDialog({
|
|
18
13
|
open,
|
|
@@ -38,9 +33,6 @@ export function DefaultDialog({
|
|
|
38
33
|
await onConfirm();
|
|
39
34
|
} finally {
|
|
40
35
|
setLoading(false);
|
|
41
|
-
// Auch bei rejected onConfirm schließen — ein offen hängendes Modal
|
|
42
|
-
// ohne Botschaft wirkt eingefroren; der Fehler selbst wird vom
|
|
43
|
-
// onConfirm-Pfad surfaced (Row-Actions: Toast).
|
|
44
36
|
onOpenChange(false);
|
|
45
37
|
}
|
|
46
38
|
}
|
|
@@ -51,72 +43,51 @@ export function DefaultDialog({
|
|
|
51
43
|
: "bg-primary text-primary-foreground shadow hover:bg-primary/90";
|
|
52
44
|
|
|
53
45
|
return (
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
46
|
+
<ModalShell
|
|
47
|
+
open={open}
|
|
48
|
+
onOpenChange={onOpenChange}
|
|
49
|
+
testId={testId}
|
|
50
|
+
closeLabel={t("kumiko.dialog.close")}
|
|
51
|
+
noAriaDescription={description === undefined}
|
|
52
|
+
contentClassName={cn("grid w-full max-w-lg gap-4 border bg-card p-6 shadow-lg rounded-lg")}
|
|
53
|
+
>
|
|
54
|
+
<div className="flex flex-col gap-1.5">
|
|
55
|
+
<DialogPrimitive.Title className="text-lg font-semibold tracking-tight">
|
|
56
|
+
{title}
|
|
57
|
+
</DialogPrimitive.Title>
|
|
58
|
+
{description !== undefined && (
|
|
59
|
+
<DialogPrimitive.Description className="text-sm text-muted-foreground">
|
|
60
|
+
{description}
|
|
61
|
+
</DialogPrimitive.Description>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
{children !== undefined && <div>{children}</div>}
|
|
65
|
+
<div className="flex items-center justify-end gap-2">
|
|
66
|
+
<DialogPrimitive.Close asChild>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
disabled={loading}
|
|
70
|
+
data-testid={testId !== undefined ? `${testId}-cancel` : undefined}
|
|
71
|
+
className="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
72
|
+
>
|
|
73
|
+
{effectiveCancelLabel}
|
|
74
|
+
</button>
|
|
75
|
+
</DialogPrimitive.Close>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => void handleConfirm()}
|
|
79
|
+
disabled={loading}
|
|
80
|
+
data-testid={testId !== undefined ? `${testId}-confirm` : undefined}
|
|
64
81
|
className={cn(
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
|
|
68
|
-
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
69
|
-
"rounded-lg",
|
|
82
|
+
"inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium",
|
|
83
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
84
|
+
confirmClass,
|
|
70
85
|
)}
|
|
71
86
|
>
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<DialogPrimitive.Description className="text-sm text-muted-foreground">
|
|
78
|
-
{description}
|
|
79
|
-
</DialogPrimitive.Description>
|
|
80
|
-
)}
|
|
81
|
-
</div>
|
|
82
|
-
{children !== undefined && <div>{children}</div>}
|
|
83
|
-
<div className="flex items-center justify-end gap-2">
|
|
84
|
-
<DialogPrimitive.Close asChild>
|
|
85
|
-
<button
|
|
86
|
-
type="button"
|
|
87
|
-
disabled={loading}
|
|
88
|
-
data-testid={testId !== undefined ? `${testId}-cancel` : undefined}
|
|
89
|
-
className="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
90
|
-
>
|
|
91
|
-
{effectiveCancelLabel}
|
|
92
|
-
</button>
|
|
93
|
-
</DialogPrimitive.Close>
|
|
94
|
-
<button
|
|
95
|
-
type="button"
|
|
96
|
-
onClick={() => void handleConfirm()}
|
|
97
|
-
disabled={loading}
|
|
98
|
-
data-testid={testId !== undefined ? `${testId}-confirm` : undefined}
|
|
99
|
-
className={cn(
|
|
100
|
-
"inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium",
|
|
101
|
-
"disabled:pointer-events-none disabled:opacity-50",
|
|
102
|
-
confirmClass,
|
|
103
|
-
)}
|
|
104
|
-
>
|
|
105
|
-
{loading && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
|
|
106
|
-
{effectiveConfirmLabel}
|
|
107
|
-
</button>
|
|
108
|
-
</div>
|
|
109
|
-
<DialogPrimitive.Close asChild>
|
|
110
|
-
<button
|
|
111
|
-
type="button"
|
|
112
|
-
aria-label={t("kumiko.dialog.close")}
|
|
113
|
-
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
114
|
-
>
|
|
115
|
-
<X className="size-4" />
|
|
116
|
-
</button>
|
|
117
|
-
</DialogPrimitive.Close>
|
|
118
|
-
</DialogPrimitive.Content>
|
|
119
|
-
</DialogPrimitive.Portal>
|
|
120
|
-
</DialogPrimitive.Root>
|
|
87
|
+
{loading && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
|
|
88
|
+
{effectiveConfirmLabel}
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
</ModalShell>
|
|
121
92
|
);
|
|
122
93
|
}
|