@cosmicdrift/kumiko-renderer-web 0.68.0 → 0.70.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__/default-app-shell.test.tsx +27 -0
- package/src/__tests__/form-action-bar.test.tsx +48 -0
- package/src/__tests__/nav-tree.test.tsx +45 -0
- package/src/layout/default-app-shell.tsx +29 -2
- package/src/layout/nav-tree.tsx +62 -22
- package/src/primitives/index.tsx +52 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.70.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.70.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.70.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",
|
|
@@ -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
|
|
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
|
|
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
|
}
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -55,7 +55,15 @@ import {
|
|
|
55
55
|
Wallet,
|
|
56
56
|
Wand2,
|
|
57
57
|
} from "lucide-react";
|
|
58
|
-
import {
|
|
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
|
-
|
|
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
|
-
<
|
|
148
|
-
{tree
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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} />
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
}
|