@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.
Files changed (79) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/action-menu.test.tsx +75 -0
  3. package/src/__tests__/breadcrumb.test.tsx +50 -0
  4. package/src/__tests__/create-app.test.tsx +171 -1
  5. package/src/__tests__/dashboard-screen.test.tsx +393 -0
  6. package/src/__tests__/default-app-shell.test.tsx +47 -1
  7. package/src/__tests__/form-action-bar.test.tsx +9 -0
  8. package/src/__tests__/kumiko-screen.test.tsx +155 -1
  9. package/src/__tests__/lightbox.test.tsx +46 -0
  10. package/src/__tests__/nav-tree.test.tsx +122 -0
  11. package/src/__tests__/primitives.test.tsx +32 -1
  12. package/src/__tests__/profile-menu.test.tsx +29 -0
  13. package/src/__tests__/projection-detail.test.tsx +101 -0
  14. package/src/__tests__/projection-list-actions.test.tsx +131 -0
  15. package/src/__tests__/render-list.test.tsx +11 -3
  16. package/src/__tests__/test-utils.tsx +1 -0
  17. package/src/__tests__/toast.test.tsx +9 -9
  18. package/src/__tests__/use-mutation.test.tsx +86 -0
  19. package/src/__tests__/workspace-shell.test.tsx +102 -47
  20. package/src/__tests__/xss-safety.test.tsx +62 -0
  21. package/src/app/__tests__/first-open-screen-qn.test.ts +77 -0
  22. package/src/app/browser-locale.ts +1 -1
  23. package/src/app/create-app.tsx +96 -29
  24. package/src/app/dashboard-body.tsx +454 -0
  25. package/src/index.ts +75 -1
  26. package/src/layout/__tests__/nav-registry.test.ts +105 -0
  27. package/src/layout/__tests__/shell-breadcrumb.test.ts +82 -0
  28. package/src/layout/default-app-shell.tsx +9 -71
  29. package/src/layout/nav-tree.tsx +167 -37
  30. package/src/layout/shell-breadcrumb.ts +46 -0
  31. package/src/layout/shell-header.tsx +114 -0
  32. package/src/layout/workspace-shell.tsx +45 -18
  33. package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
  34. package/src/primitives/__tests__/input-testid.test.tsx +45 -0
  35. package/src/primitives/__tests__/money-input.test.tsx +13 -1
  36. package/src/primitives/dialog.tsx +47 -76
  37. package/src/primitives/index.tsx +215 -37
  38. package/src/primitives/layout.tsx +39 -0
  39. package/src/primitives/lightbox.tsx +39 -0
  40. package/src/primitives/modal-shell.tsx +62 -0
  41. package/src/primitives/money-input.tsx +20 -10
  42. package/src/primitives/toast.tsx +14 -9
  43. package/src/sse/live-events.ts +6 -1
  44. package/src/styles.css +25 -0
  45. package/src/tokens.ts +4 -0
  46. package/src/ui/__tests__/avatar.test.tsx +66 -0
  47. package/src/ui/__tests__/sheet.test.tsx +102 -0
  48. package/src/version/__tests__/update-checker.test.tsx +115 -0
  49. package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
  50. package/src/widgets/__tests__/drawer.test.tsx +35 -0
  51. package/src/widgets/__tests__/form-kit.test.tsx +368 -0
  52. package/src/widgets/__tests__/infinity-list.test.tsx +132 -0
  53. package/src/widgets/__tests__/query-table.test.tsx +118 -0
  54. package/src/widgets/__tests__/result-panel.test.tsx +117 -0
  55. package/src/widgets/__tests__/widgets.test.tsx +243 -0
  56. package/src/widgets/ai-text-field.tsx +370 -0
  57. package/src/widgets/charts.tsx +228 -0
  58. package/src/widgets/collapsible-section.tsx +42 -0
  59. package/src/widgets/detail-list.tsx +43 -0
  60. package/src/widgets/drawer.tsx +49 -0
  61. package/src/widgets/feed-list.tsx +35 -0
  62. package/src/widgets/form-fields.tsx +326 -0
  63. package/src/widgets/index.ts +59 -0
  64. package/src/widgets/infinity-list.tsx +112 -0
  65. package/src/widgets/mode-switch.tsx +41 -0
  66. package/src/widgets/progress-bar.tsx +27 -0
  67. package/src/widgets/progress-list.tsx +38 -0
  68. package/src/widgets/query-table.tsx +103 -0
  69. package/src/widgets/result-panel.tsx +251 -0
  70. package/src/widgets/section-card.tsx +30 -0
  71. package/src/widgets/stat.tsx +181 -0
  72. package/src/widgets/states.tsx +89 -0
  73. package/src/widgets/status-badge.tsx +51 -0
  74. package/src/widgets/use-draft.ts +40 -0
  75. package/src/ui/card.tsx +0 -93
  76. package/src/ui/collapsible.tsx +0 -34
  77. package/src/ui/dropdown-menu.tsx +0 -258
  78. package/src/ui/select.tsx +0 -191
  79. /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 { SidebarProvider } from "../ui/sidebar";
38
- import { AppLayout } from "./app-layout";
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 { Sidebar } from "./sidebar";
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
- // SidebarProvider: NavTree rendert vendored shadcn-SidebarMenuButtons, die
208
- // useSidebar() rufen. WorkspaceShell behält sein eigenes Topbar-Layout
209
- // (AppLayout + Sidebar), braucht aber den Context-Wrapper drumherum.
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
- <AppLayout
213
- topbar={<Topbar start={brand} center={switcher || undefined} end={topbarActions} />}
214
- sidebar={
215
- <Sidebar {...(sidebarFooter !== undefined && { footer: sidebarFooter })}>
216
- {sidebarContent}
217
- </Sidebar>
218
- }
219
- >
220
- {activeTarget !== undefined ? <EditorPanel resolvers={resolvers} /> : children}
221
- </AppLayout>
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
- // shadcn-Style Dialog via @radix-ui/react-dialog. Confirm-Button-
2
- // Variant kommt aus dem Standard-Button-Pattern; Cancel ist immer
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, X } from "lucide-react";
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
- <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
55
- <DialogPrimitive.Portal>
56
- <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
57
- <DialogPrimitive.Content
58
- data-testid={testId}
59
- // Wenn description fehlt, signalisieren wir Radix explizit
60
- // `aria-describedby={undefined}` sonst warnt Radix zur
61
- // Laufzeit (aria-Best-Practice: Modals sollen entweder eine
62
- // Description haben oder den Hinweis tragen dass keine da ist).
63
- {...(description === undefined && { "aria-describedby": undefined })}
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
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg",
66
- "data-[state=open]:animate-in data-[state=closed]:animate-out",
67
- "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
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
- <div className="flex flex-col gap-1.5">
73
- <DialogPrimitive.Title className="text-lg font-semibold tracking-tight">
74
- {title}
75
- </DialogPrimitive.Title>
76
- {description !== undefined && (
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
  }