@cosmicdrift/kumiko-renderer-web 0.68.0 → 0.71.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.68.0",
3
+ "version": "0.71.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.68.0",
20
- "@cosmicdrift/kumiko-headless": "0.68.0",
21
- "@cosmicdrift/kumiko-renderer": "0.68.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.71.0",
20
+ "@cosmicdrift/kumiko-headless": "0.71.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.71.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -297,4 +297,76 @@ describe("KumikoScreen / configEdit", () => {
297
297
  });
298
298
  expect(queryCalls["config:query:values"] ?? 0).toBeGreaterThan(valuesBefore);
299
299
  });
300
+
301
+ // #432/1: der #429 Slot-Split rendert die Cascade in zwei Slots — den
302
+ // Trigger (Toggle) im labelAppendix, das Panel im fieldAppendix, beide über
303
+ // einen geteilten expandedFields-State. Der open/close-Zyklus war nur
304
+ // indirekt (Render-Snapshot) gedeckt; hier wird er als Interaktion gepinnt.
305
+ test("#432/1: cascade trigger toggles the panel slot open and closed", async () => {
306
+ const dispatcher: Dispatcher = createMockDispatcher({
307
+ query: (async (qn: string) => {
308
+ if (qn === "config:query:cascade") {
309
+ return {
310
+ isSuccess: true,
311
+ data: {
312
+ "demo:config:site-name": {
313
+ value: "Acme",
314
+ source: "tenant-row",
315
+ levels: [
316
+ {
317
+ source: "tenant-row",
318
+ label: "tenant-row",
319
+ value: "Acme",
320
+ isActive: true,
321
+ hasValue: true,
322
+ },
323
+ {
324
+ source: "default",
325
+ label: "default",
326
+ value: "fallback",
327
+ isActive: false,
328
+ hasValue: true,
329
+ },
330
+ ],
331
+ },
332
+ },
333
+ };
334
+ }
335
+ return {
336
+ isSuccess: true,
337
+ data: {
338
+ "demo:config:site-name": { value: "Acme", scope: "tenant", source: "tenant-row" },
339
+ },
340
+ };
341
+ }) as unknown as Dispatcher["query"],
342
+ });
343
+
344
+ const user = userEvent.setup();
345
+ render(
346
+ <DispatcherProvider dispatcher={dispatcher}>
347
+ <KumikoScreen schema={schema} qn="demo:screen:settings" />
348
+ </DispatcherProvider>,
349
+ );
350
+
351
+ await waitFor(() => screen.getByTestId("render-edit-form"));
352
+ const field = screen.getByTestId("field-siteName");
353
+ await waitFor(() =>
354
+ expect(field.querySelector('[data-testid="config-cascade"]')).not.toBeNull(),
355
+ );
356
+
357
+ // collapsed: panel slot is absent → the inactive default level ("fallback")
358
+ // is not in the DOM (the trigger only shows the active value "Acme").
359
+ expect(screen.queryByText("fallback")).toBeNull();
360
+
361
+ const toggle = field.querySelector('[data-testid="config-cascade"]')?.querySelector("button");
362
+ if (!toggle) throw new Error("cascade trigger toggle not found");
363
+
364
+ // open → panel slot renders all levels incl. the inactive default value
365
+ await user.click(toggle);
366
+ await waitFor(() => expect(screen.getByText("fallback")).toBeTruthy());
367
+
368
+ // close → panel slot gone again (shared expandedFields state cleared)
369
+ await user.click(toggle);
370
+ await waitFor(() => expect(screen.queryByText("fallback")).toBeNull());
371
+ });
300
372
  });
@@ -57,3 +57,30 @@ describe("DefaultAppShell user-prop forwarding", () => {
57
57
  expect(screen.getByText("Sysadmin")).toBeTruthy();
58
58
  });
59
59
  });
60
+
61
+ describe("DefaultAppShell headerActions-Slot (Topbar rechts)", () => {
62
+ test("headerActions rendern rechtsbündig im Topbar-Header", () => {
63
+ render(
64
+ <DefaultAppShell
65
+ brand={<span>Brand</span>}
66
+ schema={makeSchema()}
67
+ headerActions={<button type="button">Theme</button>}
68
+ >
69
+ <div>content</div>
70
+ </DefaultAppShell>,
71
+ );
72
+ const slot = document.querySelector("[data-kumiko-layout='header-actions']");
73
+ expect(slot).toBeTruthy();
74
+ expect(slot?.className).toContain("ml-auto");
75
+ expect(slot?.textContent).toBe("Theme");
76
+ });
77
+
78
+ test("ohne headerActions → kein Slot (keine leere Zelle)", () => {
79
+ render(
80
+ <DefaultAppShell brand={<span>Brand</span>} schema={makeSchema()}>
81
+ <div>content</div>
82
+ </DefaultAppShell>,
83
+ );
84
+ expect(document.querySelector("[data-kumiko-layout='header-actions']")).toBeNull();
85
+ });
86
+ });
@@ -95,6 +95,54 @@ describe("Form = eine Card, Sections als innere Abschnitte", () => {
95
95
  });
96
96
  });
97
97
 
98
+ describe("DefaultSection Card-Standard (subtitle + actions-Footer)", () => {
99
+ test("standalone: subtitle rendert muted, KEIN Divider unterm Titel", () => {
100
+ render(
101
+ <Section testId="sc" title="Ergebnis" subtitle="Kontext-Zeile">
102
+ <div>x</div>
103
+ </Section>,
104
+ );
105
+ expect(screen.getByTestId("sc-subtitle").textContent).toBe("Kontext-Zeile");
106
+ expect(screen.getByTestId("sc-subtitle").className).toContain("text-muted-foreground");
107
+ // border-b-Header (alt) ist weg — Titel fließt in den Body (shadcn-Muster).
108
+ expect(screen.getByTestId("sc").querySelector(".border-b")).toBeNull();
109
+ });
110
+
111
+ test("standalone: actions = abgehobene Footer-Row (border-t)", () => {
112
+ render(
113
+ <Section testId="sc" title="Ergebnis" actions={<Button>Übernehmen</Button>}>
114
+ <div>x</div>
115
+ </Section>,
116
+ );
117
+ const actions = screen.getByTestId("sc-actions");
118
+ expect(actions.className).toContain("border-t");
119
+ expect(actions.className).toContain("justify-end");
120
+ expect(actions.textContent).toContain("Übernehmen");
121
+ });
122
+
123
+ test("title-only standalone (Bestands-Consumer) hat keinen Divider mehr", () => {
124
+ render(
125
+ <Section testId="legacy" title="Stammdaten">
126
+ <div>x</div>
127
+ </Section>,
128
+ );
129
+ expect(screen.getByTestId("legacy").querySelector(".border-b")).toBeNull();
130
+ });
131
+
132
+ test("im Form: actions sind rechtsbündig OHNE border-t (das Form trägt den Footer)", () => {
133
+ render(
134
+ <Form onSubmit={() => {}} testId="f">
135
+ <Section testId="inner" actions={<Button>X</Button>}>
136
+ <div>x</div>
137
+ </Section>
138
+ </Form>,
139
+ );
140
+ const actions = screen.getByTestId("inner-actions");
141
+ expect(actions.className).toContain("justify-end");
142
+ expect(actions.className).not.toContain("border-t");
143
+ });
144
+ });
145
+
98
146
  const orderEntity = {
99
147
  fields: { title: { type: "text", required: true } },
100
148
  } as unknown as EntityDefinition; // @cast-boundary test-fixture
@@ -236,6 +236,51 @@ describe("NavTree", () => {
236
236
  });
237
237
  });
238
238
 
239
+ describe("NavTree navBadges (Runtime-Badge-Slot)", () => {
240
+ // Tier-Badge & Co: die App liefert per bare nav-id einen ReactNode (Wert
241
+ // UND Farbe). Gekeyt auf lastSegment(qualifiedName) → die App schreibt
242
+ // "tarif", nicht "showcase:nav:tarif".
243
+ function flatSchema(): FeatureSchema {
244
+ return {
245
+ featureName: "showcase",
246
+ entities: {},
247
+ screens: [
248
+ { id: "tarif", type: "entityList", entity: "x", columns: [] },
249
+ { id: "plain", type: "entityList", entity: "x", columns: [] },
250
+ ],
251
+ navs: [
252
+ { id: "tarif", label: "Tarif & Limits", screen: "tarif", order: 10 },
253
+ { id: "plain", label: "Plain", screen: "plain", order: 20 },
254
+ ],
255
+ } as FeatureSchema;
256
+ }
257
+
258
+ test("Badge gekeyt auf bare nav-id sitzt im passenden Item, nicht in anderen", () => {
259
+ render(<NavTree schema={flatSchema()} navBadges={new Map([["tarif", <span>Pro</span>]])} />);
260
+ const badge = screen.getByText("Pro");
261
+ expect(screen.getByText("Tarif & Limits").closest("li")?.textContent).toContain("Pro");
262
+ expect(screen.getByText("Plain").closest("li")?.textContent).not.toContain("Pro");
263
+ // Slot-Wrapper schiebt rechts (ml-auto), Badge selbst shrinkt nicht weg.
264
+ expect(badge.parentElement?.className).toContain("ml-auto");
265
+ });
266
+
267
+ test("ohne navBadges → kein Badge-Slot", () => {
268
+ render(<NavTree schema={flatSchema()} />);
269
+ expect(screen.queryByText("Pro")).toBeNull();
270
+ });
271
+
272
+ test("Key ohne passendes Item → nichts (silent), übrige Items rendern normal", () => {
273
+ render(
274
+ <NavTree
275
+ schema={flatSchema()}
276
+ navBadges={new Map([["does-not-exist", <span>Ghost</span>]])}
277
+ />,
278
+ );
279
+ expect(screen.queryByText("Ghost")).toBeNull();
280
+ expect(screen.getByText("Tarif & Limits")).toBeTruthy();
281
+ });
282
+ });
283
+
239
284
  // ── Visual-Tree-Merge: dynamische Knoten in der EINEN Nav ──────────────
240
285
  //
241
286
  // Beweist die vier Caps die NavTree aus dem alten VisualTree übernimmt:
@@ -8,6 +8,8 @@
8
8
  // 2. sidebarActions — Icon-Row (Search/Theme/Tenant-Switch)
9
9
  // 3. NavTree — automatisch aus dem Schema
10
10
  // 4. sidebarFooter — Bottom-Slot für Profile/Help/Plan-Banner
11
+ // Topbar-Slot:
12
+ // headerActions — rechtsbündig neben dem Breadcrumb (ThemeToggle etc.)
11
13
  //
12
14
  // Apps die feingranulare Kontrolle wollen, gehen direkt auf die ui/-Teile.
13
15
  // Wer ein Topbar mit Workspace-Switcher braucht, nutzt <WorkspaceShell>.
@@ -53,6 +55,14 @@ export type DefaultAppShellProps = {
53
55
  /** Icon-Row zwischen Brand und NavTree — typisch Search-Trigger,
54
56
  * ThemeToggle, TenantSwitcher. */
55
57
  readonly sidebarActions?: ReactNode;
58
+ /** Runtime-Badges pro Nav-Item (bare nav-id → ReactNode), an NavTree
59
+ * durchgereicht. Für Tier-/Status-Badges, die die App pro User liefert
60
+ * (Wert + Farbe), statt sie ins statische Schema zu backen. */
61
+ readonly navBadges?: ReadonlyMap<string, ReactNode>;
62
+ /** Rechtsbündiger Slot in der Topbar (neben dem Breadcrumb) — für
63
+ * ThemeToggle, globale Actions o.ä. Spart die eigene sidebarActions-
64
+ * Zeile in der Sidebar. */
65
+ readonly headerActions?: ReactNode;
56
66
  /** Footer-Slot unten in der Sidebar — Profile-Row, Help-Link,
57
67
  * Plan-Banner. */
58
68
  readonly sidebarFooter?: ReactNode;
@@ -68,6 +78,8 @@ export function DefaultAppShell({
68
78
  schema,
69
79
  user,
70
80
  sidebarActions,
81
+ headerActions,
82
+ navBadges,
71
83
  sidebarFooter,
72
84
  fill,
73
85
  children,
@@ -87,7 +99,11 @@ export function DefaultAppShell({
87
99
  </SidebarGroup>
88
100
  )}
89
101
  <SidebarContent data-kumiko-layout="sidebar-nav">
90
- <NavTree schema={schema} {...(user !== undefined && { user })} />
102
+ <NavTree
103
+ schema={schema}
104
+ {...(user !== undefined && { user })}
105
+ {...(navBadges !== undefined && { navBadges })}
106
+ />
91
107
  </SidebarContent>
92
108
  {sidebarFooter !== undefined && (
93
109
  <SidebarFooter data-kumiko-layout="sidebar-footer">{sidebarFooter}</SidebarFooter>
@@ -95,7 +111,11 @@ export function DefaultAppShell({
95
111
  <SidebarRail />
96
112
  </Sidebar>
97
113
  <SidebarInset className={fill === true ? "min-h-0" : undefined}>
98
- <ShellHeader schema={schema} {...(user !== undefined && { user })} />
114
+ <ShellHeader
115
+ schema={schema}
116
+ {...(user !== undefined && { user })}
117
+ {...(headerActions !== undefined && { headerActions })}
118
+ />
99
119
  <main className={fill === true ? "min-h-0 flex-1 overflow-auto" : "flex-1 overflow-auto"}>
100
120
  {children}
101
121
  </main>
@@ -110,9 +130,11 @@ export function DefaultAppShell({
110
130
  function ShellHeader({
111
131
  schema,
112
132
  user,
133
+ headerActions,
113
134
  }: {
114
135
  readonly schema: AppSchema | FeatureSchema;
115
136
  readonly user?: DefaultAppShellUser;
137
+ readonly headerActions?: ReactNode;
116
138
  }): ReactNode {
117
139
  const nav = useNav();
118
140
  const t = useTranslation();
@@ -139,6 +161,11 @@ function ShellHeader({
139
161
  </Breadcrumb>
140
162
  )}
141
163
  </div>
164
+ {headerActions !== undefined && (
165
+ <div data-kumiko-layout="header-actions" className="ml-auto flex items-center gap-2 px-4">
166
+ {headerActions}
167
+ </div>
168
+ )}
142
169
  </header>
143
170
  );
144
171
  }
@@ -55,7 +55,15 @@ import {
55
55
  Wallet,
56
56
  Wand2,
57
57
  } from "lucide-react";
58
- import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
58
+ import {
59
+ createContext,
60
+ type ReactNode,
61
+ useCallback,
62
+ useContext,
63
+ useEffect,
64
+ useMemo,
65
+ useState,
66
+ } from "react";
59
67
  import { KumikoLink } from "../app/nav";
60
68
  import { useNavEntities, useNavProviders } from "../app/nav-providers-context";
61
69
  import { cn } from "../lib/cn";
@@ -121,9 +129,23 @@ export type NavTreeProps = {
121
129
  // workspace's `navMembers` list. Undefined = no filter (legacy / non-
122
130
  // workspace apps render every nav).
123
131
  readonly allowedNavQns?: ReadonlySet<string>;
132
+ // Runtime-Badges pro Nav-Item, gekeyt auf die BARE nav-id (lastSegment der
133
+ // qualifiedName, also `"tarif"` nicht `"app:nav:tarif"`). Wert ist frei —
134
+ // die App liefert Text UND Farbe (`<Badge className="bg-amber-…">Pro</Badge>`).
135
+ // Für dynamische Zustände (Tier-Badge), die nicht ins statische Schema gehören.
136
+ readonly navBadges?: ReadonlyMap<string, ReactNode>;
124
137
  };
125
138
 
126
- export function NavTree({ schema, user, testId, allowedNavQns }: NavTreeProps): ReactNode {
139
+ const EMPTY_BADGES: ReadonlyMap<string, ReactNode> = new Map();
140
+ const NavBadgesContext = createContext<ReadonlyMap<string, ReactNode>>(EMPTY_BADGES);
141
+
142
+ export function NavTree({
143
+ schema,
144
+ user,
145
+ testId,
146
+ allowedNavQns,
147
+ navBadges,
148
+ }: NavTreeProps): ReactNode {
127
149
  const app = useMemo(() => toAppSchema(schema), [schema]);
128
150
  const tree = useMemo(() => {
129
151
  const source = buildNavRegistrySliceForApp(app, allowedNavQns);
@@ -144,25 +166,39 @@ export function NavTree({ schema, user, testId, allowedNavQns }: NavTreeProps):
144
166
  }, []);
145
167
 
146
168
  return (
147
- <div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
148
- {tree.map((node) =>
149
- isPureSection(node) ? (
150
- <NavSection
151
- key={node.qualifiedName}
152
- node={node}
153
- collapsed={collapsed}
154
- onToggle={onToggle}
155
- />
156
- ) : (
157
- <SidebarMenu key={node.qualifiedName} className="px-2 py-1">
158
- <NavMenuNode node={node} collapsed={collapsed} onToggle={onToggle} />
159
- </SidebarMenu>
160
- ),
161
- )}
162
- </div>
169
+ <NavBadgesContext.Provider value={navBadges ?? EMPTY_BADGES}>
170
+ <div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
171
+ {tree.map((node) =>
172
+ isPureSection(node) ? (
173
+ <NavSection
174
+ key={node.qualifiedName}
175
+ node={node}
176
+ collapsed={collapsed}
177
+ onToggle={onToggle}
178
+ />
179
+ ) : (
180
+ <SidebarMenu key={node.qualifiedName} className="px-2 py-1">
181
+ <NavMenuNode node={node} collapsed={collapsed} onToggle={onToggle} />
182
+ </SidebarMenu>
183
+ ),
184
+ )}
185
+ </div>
186
+ </NavBadgesContext.Provider>
163
187
  );
164
188
  }
165
189
 
190
+ // Runtime-Badge-Slot rechts am Label, gekeyt auf die bare nav-id. Kein
191
+ // Eintrag → nichts (silent, kein Layout-Shift). shrink-0 + die ml-auto
192
+ // schiebt ihn ans rechte Ende; das truncate-Label gibt nach.
193
+ // ponytail: nur an screen/target-Leaves verdrahtet, nicht an Container-
194
+ // Knoten (die tragen den ml-auto-Chevron) — Tier-Badge ist ein Leaf.
195
+ function NavBadge({ node }: { readonly node: NavNode }): ReactNode {
196
+ const badges = useContext(NavBadgesContext);
197
+ const badge = badges.get(lastSegment(node.qualifiedName));
198
+ if (badge === undefined) return null;
199
+ return <span className="ml-auto shrink-0">{badge}</span>;
200
+ }
201
+
166
202
  // Pure section = ein Gruppen-Header ohne eigenen Screen, der nur seine
167
203
  // children gruppiert. Auf Top-Level wird daraus eine SidebarGroup mit
168
204
  // togglebarem GroupLabel; ein Node mit Screen (auch wenn er children hat)
@@ -502,7 +538,8 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
502
538
  {...(s.active && { "aria-current": "page" })}
503
539
  >
504
540
  <NavLeadingIcon node={node} active={s.active} />
505
- <span className="truncate">{s.displayLabel}</span>
541
+ <span className="min-w-0 truncate">{s.displayLabel}</span>
542
+ <NavBadge node={node} />
506
543
  </KumikoLink>
507
544
  </SidebarMenuButton>
508
545
  <NodeActions node={node} />
@@ -522,7 +559,8 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
522
559
  onClick={() => dispatch(target)}
523
560
  >
524
561
  <NavLeadingIcon node={node} active={s.active} />
525
- <span className="truncate">{s.displayLabel}</span>
562
+ <span className="min-w-0 truncate">{s.displayLabel}</span>
563
+ <NavBadge node={node} />
526
564
  </SidebarMenuButton>
527
565
  <NodeActions node={node} />
528
566
  {chevron}
@@ -603,7 +641,8 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
603
641
  {...(s.active && { "aria-current": "page" })}
604
642
  >
605
643
  <NavLeadingIcon node={node} active={s.active} />
606
- <span className="truncate">{s.displayLabel}</span>
644
+ <span className="min-w-0 truncate">{s.displayLabel}</span>
645
+ <NavBadge node={node} />
607
646
  </KumikoLink>
608
647
  </SidebarMenuSubButton>
609
648
  <NodeActions node={node} />
@@ -620,7 +659,8 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
620
659
  <SidebarMenuSubButton asChild isActive={s.active}>
621
660
  <button type="button" onClick={() => dispatch(target)}>
622
661
  <NavLeadingIcon node={node} active={s.active} />
623
- <span className="truncate">{s.displayLabel}</span>
662
+ <span className="min-w-0 truncate">{s.displayLabel}</span>
663
+ <NavBadge node={node} />
624
664
  </button>
625
665
  </SidebarMenuSubButton>
626
666
  <NodeActions node={node} />
@@ -66,6 +66,9 @@ export function FileUploadInput({
66
66
  setError("upload_failed");
67
67
  } finally {
68
68
  setUploading(false);
69
+ // Clear the input so re-picking the SAME file still fires change — the
70
+ // browser suppresses the event otherwise (no-op in jsdom, real-browser).
71
+ if (inputRef.current) inputRef.current.value = "";
69
72
  }
70
73
  }
71
74
 
@@ -57,7 +57,7 @@ import {
57
57
  import { cn } from "../lib/cn";
58
58
  import { Badge } from "../ui/badge";
59
59
  import { Button as UiButton } from "../ui/button";
60
- import { Card, CardContent, CardHeader } from "../ui/card";
60
+ import { Card, CardContent } from "../ui/card";
61
61
  import { Checkbox } from "../ui/checkbox";
62
62
  import { Input as UiInput } from "../ui/input";
63
63
  import { Label as UiLabel } from "../ui/label";
@@ -1393,34 +1393,70 @@ function DefaultForm({
1393
1393
  );
1394
1394
  }
1395
1395
 
1396
- function DefaultSection({ title, children, testId }: SectionProps): ReactNode {
1396
+ function DefaultSection({ title, subtitle, children, actions, testId }: SectionProps): ReactNode {
1397
1397
  const insideForm = useContext(InsideFormContext);
1398
1398
 
1399
+ // h3 statt CardTitle (= div): erhält die Heading-Semantik für
1400
+ // Screenreader-Navigation. Subtitle fließt darunter (kein Divider —
1401
+ // shadcn CardTitle+CardDescription-Muster).
1402
+ const header =
1403
+ title !== undefined || subtitle !== undefined ? (
1404
+ <div className="flex flex-col gap-1">
1405
+ {title !== undefined && (
1406
+ <h3
1407
+ data-testid={testId !== undefined ? `${testId}-title` : undefined}
1408
+ className="text-base font-semibold leading-none tracking-tight"
1409
+ >
1410
+ {title}
1411
+ </h3>
1412
+ )}
1413
+ {subtitle !== undefined && (
1414
+ <p
1415
+ data-testid={testId !== undefined ? `${testId}-subtitle` : undefined}
1416
+ className="text-sm text-muted-foreground"
1417
+ >
1418
+ {subtitle}
1419
+ </p>
1420
+ )}
1421
+ </div>
1422
+ ) : null;
1423
+
1399
1424
  // Innerhalb eines Forms: divider-loser Abschnitt OHNE eigene Card-Fläche.
1400
- // Die Trennlinien ZWISCHEN Sections macht der divide-y-Wrapper im Form
1401
- // keine Linie unter dem Titel, keine vor dem Footer.
1425
+ // Die Trennlinien ZWISCHEN Sections macht der divide-y-Wrapper im Form.
1426
+ // actions hier = rechtsbündige Reihe (das Form trägt den eigenen Footer).
1402
1427
  if (insideForm) {
1403
1428
  return (
1404
1429
  <section data-testid={testId} className="flex flex-col gap-4 px-6 py-6">
1405
- {title !== undefined && (
1406
- <h3 className="text-base font-semibold leading-none tracking-tight">{title}</h3>
1407
- )}
1430
+ {header}
1408
1431
  {children}
1432
+ {actions !== undefined && (
1433
+ <div
1434
+ data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1435
+ className="flex items-center justify-end gap-2"
1436
+ >
1437
+ {actions}
1438
+ </div>
1439
+ )}
1409
1440
  </section>
1410
1441
  );
1411
1442
  }
1412
1443
 
1413
- // Standalone: eigene Card (abgesetzter border-b-Header + gap-4-Body).
1444
+ // Standalone: eigene Card, Header fließt in den Body (kein Divider).
1445
+ // actions = abgehobene Footer-Row (border-t bg-muted/30, wie DefaultForm).
1414
1446
  return (
1415
- <Card data-testid={testId} className="gap-0 rounded-lg py-0">
1416
- {title !== undefined && (
1417
- <CardHeader className="border-b px-6 py-4">
1418
- {/* h3 statt CardTitle (= div): erhält die Heading-Semantik für
1419
- Screenreader-Navigation. Klassen = CardTitle-Default. */}
1420
- <h3 className="text-base font-semibold leading-none tracking-tight">{title}</h3>
1421
- </CardHeader>
1447
+ <Card data-testid={testId} className="gap-0 overflow-hidden rounded-lg py-0">
1448
+ <CardContent className="flex flex-col gap-4 px-6 py-6">
1449
+ {header}
1450
+ {children}
1451
+ </CardContent>
1452
+ {actions !== undefined && (
1453
+ <div
1454
+ data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1455
+ className="flex items-center justify-end gap-2 border-t bg-muted/30 px-6 py-4"
1456
+ >
1457
+ {actions}
1458
+ </div>
1422
1459
  )}
1423
- <CardContent className="flex flex-col gap-4 px-6 py-6">{children}</CardContent>
1424
1460
  </Card>
1425
1461
  );
1426
1462
  }