@cosmicdrift/kumiko-renderer-web 0.254.0 → 0.255.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 +5 -5
- package/src/__tests__/kumiko-screen.test.tsx +20 -30
- package/src/__tests__/primitives.test.tsx +75 -9
- package/src/__tests__/projection-detail-record-layout.test.tsx +96 -0
- package/src/__tests__/projection-detail.test.tsx +102 -0
- package/src/layout/__tests__/sidebar-panel.test.tsx +32 -0
- package/src/layout/sidebar-panel.tsx +11 -2
- package/src/primitives/dialog.tsx +11 -1
- package/src/primitives/index.tsx +289 -88
- package/src/primitives/metric.tsx +22 -3
- package/src/primitives/modal-shell.tsx +5 -0
- package/src/primitives/tabs.tsx +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.255.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.255.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.255.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.255.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",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"jsdom": "^29.1.1",
|
|
66
66
|
"tailwindcss": "^4.3.0",
|
|
67
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
67
|
+
"@cosmicdrift/kumiko-locale-de": "0.255.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -3169,8 +3169,8 @@ describe("KumikoScreen: entityList cell rendering", () => {
|
|
|
3169
3169
|
columns: ["title", "status"],
|
|
3170
3170
|
};
|
|
3171
3171
|
|
|
3172
|
-
//
|
|
3173
|
-
//
|
|
3172
|
+
// No action here is id:"edit", so the primary+kebab split (bedienkonzept
|
|
3173
|
+
// L3/L4) falls back to the first declared action ("publish").
|
|
3174
3174
|
const rowActionListScreen: EntityListScreenDefinition = {
|
|
3175
3175
|
id: "order-list-actions",
|
|
3176
3176
|
type: "entityList",
|
|
@@ -3183,8 +3183,8 @@ describe("KumikoScreen: entityList cell rendering", () => {
|
|
|
3183
3183
|
],
|
|
3184
3184
|
};
|
|
3185
3185
|
|
|
3186
|
-
// "review" has no icon in ACTION_ICON_BY_ID
|
|
3187
|
-
//
|
|
3186
|
+
// "review" has no icon in ACTION_ICON_BY_ID — proves the primary+kebab
|
|
3187
|
+
// split no longer depends on every action resolving an icon.
|
|
3188
3188
|
const mixedActionListScreen: EntityListScreenDefinition = {
|
|
3189
3189
|
id: "order-list-mixed",
|
|
3190
3190
|
type: "entityList",
|
|
@@ -3197,8 +3197,8 @@ describe("KumikoScreen: entityList cell rendering", () => {
|
|
|
3197
3197
|
],
|
|
3198
3198
|
};
|
|
3199
3199
|
|
|
3200
|
-
// projectionList reaches the same RenderList/DataTable path, so the
|
|
3201
|
-
// has to hold there too.
|
|
3200
|
+
// projectionList reaches the same RenderList/DataTable path, so the
|
|
3201
|
+
// primary+kebab split has to hold there too.
|
|
3202
3202
|
const projectionActionListScreen: ProjectionListScreenDefinition = {
|
|
3203
3203
|
id: "order-projection-actions",
|
|
3204
3204
|
type: "projectionList",
|
|
@@ -3288,40 +3288,30 @@ describe("KumikoScreen: entityList cell rendering", () => {
|
|
|
3288
3288
|
expect(badge?.className).toContain("text-muted-foreground");
|
|
3289
3289
|
});
|
|
3290
3290
|
|
|
3291
|
-
test(
|
|
3291
|
+
test('three row actions, none id:"edit" → the first is the primary text button, the rest sit in the kebab (bedienkonzept L3/L4)', async () => {
|
|
3292
3292
|
await renderOrderList("orders:screen:order-list-actions", [{ id: "r1", title: "First" }]);
|
|
3293
3293
|
|
|
3294
|
-
expect(screen.
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
["duplicate", "Duplicate"],
|
|
3299
|
-
] as const) {
|
|
3300
|
-
const button = screen.getByTestId(`row-r1-action-${id}`);
|
|
3301
|
-
expect(button.textContent).toBe("");
|
|
3302
|
-
expect(button.getAttribute("aria-label")).toBe(label);
|
|
3303
|
-
}
|
|
3294
|
+
expect(screen.getByTestId("row-r1-action-publish").textContent).toBe("Publish");
|
|
3295
|
+
expect(screen.queryByTestId("row-r1-action-archive")).toBeNull();
|
|
3296
|
+
expect(screen.queryByTestId("row-r1-action-duplicate")).toBeNull();
|
|
3297
|
+
expect(screen.queryByTestId("row-r1-actions-menu")).not.toBeNull();
|
|
3304
3298
|
});
|
|
3305
3299
|
|
|
3306
|
-
test("projectionList row actions
|
|
3300
|
+
test("projectionList row actions split the same way (bedienkonzept L3/L4)", async () => {
|
|
3307
3301
|
await renderOrderList("orders:screen:order-projection-actions", [{ id: "r1", title: "First" }]);
|
|
3308
3302
|
|
|
3309
|
-
expect(screen.
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
["duplicate", "Duplicate"],
|
|
3314
|
-
] as const) {
|
|
3315
|
-
const button = screen.getByTestId(`row-r1-action-${id}`);
|
|
3316
|
-
expect(button.textContent).toBe("");
|
|
3317
|
-
expect(button.getAttribute("aria-label")).toBe(label);
|
|
3318
|
-
}
|
|
3303
|
+
expect(screen.getByTestId("row-r1-action-publish").textContent).toBe("Publish");
|
|
3304
|
+
expect(screen.queryByTestId("row-r1-action-archive")).toBeNull();
|
|
3305
|
+
expect(screen.queryByTestId("row-r1-action-duplicate")).toBeNull();
|
|
3306
|
+
expect(screen.queryByTestId("row-r1-actions-menu")).not.toBeNull();
|
|
3319
3307
|
});
|
|
3320
3308
|
|
|
3321
|
-
test("a row-action group with an icon-less member
|
|
3309
|
+
test("a row-action group with an icon-less member still gets the primary+kebab split (bedienkonzept L3/L4)", async () => {
|
|
3322
3310
|
await renderOrderList("orders:screen:order-list-mixed", [{ id: "r1", title: "First" }]);
|
|
3323
3311
|
|
|
3312
|
+
expect(screen.getByTestId("row-r1-action-publish").textContent).toBe("Publish");
|
|
3313
|
+
expect(screen.queryByTestId("row-r1-action-archive")).toBeNull();
|
|
3314
|
+
expect(screen.queryByTestId("row-r1-action-review")).toBeNull();
|
|
3324
3315
|
expect(screen.queryByTestId("row-r1-actions-menu")).not.toBeNull();
|
|
3325
|
-
expect(screen.queryByTestId("row-r1-action-publish")).toBeNull();
|
|
3326
3316
|
});
|
|
3327
3317
|
});
|
|
@@ -963,7 +963,7 @@ describe("DataTable", () => {
|
|
|
963
963
|
expect(screen.queryByTestId("row-r1-actions-menu")).toBeNull();
|
|
964
964
|
});
|
|
965
965
|
|
|
966
|
-
test(">2 Actions: Kebab-Dropdown
|
|
966
|
+
test(">2 Actions: primäre Aktion inline + Rest im Kebab-Dropdown", () => {
|
|
967
967
|
render(
|
|
968
968
|
<DataTable
|
|
969
969
|
columns={cols}
|
|
@@ -976,10 +976,39 @@ describe("DataTable", () => {
|
|
|
976
976
|
]}
|
|
977
977
|
/>,
|
|
978
978
|
);
|
|
979
|
+
// "a" is the primary action (no id:"edit" declared, falls back to the
|
|
980
|
+
// first visible action) — it renders as its own inline button.
|
|
981
|
+
expect(screen.queryByTestId("row-r1-action-a")).not.toBeNull();
|
|
979
982
|
expect(screen.queryByTestId("row-r1-actions-menu")).not.toBeNull();
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
expect(screen.queryByTestId("row-r1-action-
|
|
983
|
+
// The rest sit behind the kebab — Radix portal'd content is only in
|
|
984
|
+
// the DOM after the trigger is clicked.
|
|
985
|
+
expect(screen.queryByTestId("row-r1-action-b")).toBeNull();
|
|
986
|
+
expect(screen.queryByTestId("row-r1-action-c")).toBeNull();
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test("4 Zeilen-Aktionen inkl. edit → Bearbeiten bleibt Text-Button, der Rest nur über den Kebab (bedienkonzept L3/L4)", async () => {
|
|
990
|
+
const user = userEvent.setup();
|
|
991
|
+
render(
|
|
992
|
+
<DataTable
|
|
993
|
+
columns={cols}
|
|
994
|
+
rows={rows}
|
|
995
|
+
testId="dt"
|
|
996
|
+
rowActions={[
|
|
997
|
+
{ id: "duplicate", label: "Duplicate", onTrigger: mock() },
|
|
998
|
+
{ id: "edit", label: "Bearbeiten", onTrigger: mock() },
|
|
999
|
+
{ id: "archive", label: "Archive", onTrigger: mock() },
|
|
1000
|
+
{ id: "delete", label: "Delete", style: "danger", onTrigger: mock() },
|
|
1001
|
+
]}
|
|
1002
|
+
/>,
|
|
1003
|
+
);
|
|
1004
|
+
expect(screen.getByTestId("row-r1-action-edit").textContent).toBe("Bearbeiten");
|
|
1005
|
+
expect(screen.queryByTestId("row-r1-action-duplicate")).toBeNull();
|
|
1006
|
+
expect(screen.queryByTestId("row-r1-action-archive")).toBeNull();
|
|
1007
|
+
expect(screen.queryByTestId("row-r1-action-delete")).toBeNull();
|
|
1008
|
+
await user.click(screen.getByTestId("row-r1-actions-menu"));
|
|
1009
|
+
expect(screen.queryByTestId("row-r1-action-duplicate")).not.toBeNull();
|
|
1010
|
+
expect(screen.queryByTestId("row-r1-action-archive")).not.toBeNull();
|
|
1011
|
+
expect(screen.queryByTestId("row-r1-action-delete")).not.toBeNull();
|
|
983
1012
|
});
|
|
984
1013
|
|
|
985
1014
|
test("rowActionMode='inline': IMMER Inline-Buttons, kein Kebab auch bei >2 (#9)", () => {
|
|
@@ -1017,7 +1046,7 @@ describe("DataTable", () => {
|
|
|
1017
1046
|
expect(group?.className).toContain("w-full");
|
|
1018
1047
|
});
|
|
1019
1048
|
|
|
1020
|
-
test("Kebab: Click auf Trigger öffnet Dropdown mit
|
|
1049
|
+
test("Kebab: Click auf Trigger öffnet Dropdown mit dem nicht-primären Rest", async () => {
|
|
1021
1050
|
const user = userEvent.setup();
|
|
1022
1051
|
render(
|
|
1023
1052
|
<DataTable
|
|
@@ -1031,8 +1060,9 @@ describe("DataTable", () => {
|
|
|
1031
1060
|
]}
|
|
1032
1061
|
/>,
|
|
1033
1062
|
);
|
|
1034
|
-
|
|
1063
|
+
// "a" is the primary action and already inline before the kebab opens.
|
|
1035
1064
|
expect(screen.queryByTestId("row-r1-action-a")).not.toBeNull();
|
|
1065
|
+
await user.click(screen.getByTestId("row-r1-actions-menu"));
|
|
1036
1066
|
expect(screen.queryByTestId("row-r1-action-b")).not.toBeNull();
|
|
1037
1067
|
expect(screen.queryByTestId("row-r1-action-c")).not.toBeNull();
|
|
1038
1068
|
});
|
|
@@ -1046,14 +1076,14 @@ describe("DataTable", () => {
|
|
|
1046
1076
|
rows={rows}
|
|
1047
1077
|
testId="dt"
|
|
1048
1078
|
rowActions={[
|
|
1049
|
-
{ id: "a", label: "Archive", onTrigger },
|
|
1050
|
-
{ id: "b", label: "Duplicate", onTrigger
|
|
1079
|
+
{ id: "a", label: "Archive", onTrigger: mock() },
|
|
1080
|
+
{ id: "b", label: "Duplicate", onTrigger },
|
|
1051
1081
|
{ id: "c", label: "Export", onTrigger: mock() },
|
|
1052
1082
|
]}
|
|
1053
1083
|
/>,
|
|
1054
1084
|
);
|
|
1055
1085
|
await user.click(screen.getByTestId("row-r1-actions-menu"));
|
|
1056
|
-
await user.click(screen.getByTestId("row-r1-action-
|
|
1086
|
+
await user.click(screen.getByTestId("row-r1-action-b"));
|
|
1057
1087
|
// micro-task warten (onTrigger ist async im Hook)
|
|
1058
1088
|
await new Promise((r) => setTimeout(r, 0));
|
|
1059
1089
|
expect(onTrigger).toHaveBeenCalledWith(rows[0]);
|
|
@@ -1681,6 +1711,42 @@ describe("Dialog", () => {
|
|
|
1681
1711
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
|
1682
1712
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
1683
1713
|
});
|
|
1714
|
+
|
|
1715
|
+
test("without children: initial focus lands on Confirm, and Enter there confirms (not Cancel)", async () => {
|
|
1716
|
+
const user = userEvent.setup();
|
|
1717
|
+
const onConfirm = mock();
|
|
1718
|
+
render(
|
|
1719
|
+
<Dialog
|
|
1720
|
+
open
|
|
1721
|
+
onOpenChange={() => undefined}
|
|
1722
|
+
title="Wirklich löschen?"
|
|
1723
|
+
onConfirm={onConfirm}
|
|
1724
|
+
testId="dlg"
|
|
1725
|
+
/>,
|
|
1726
|
+
);
|
|
1727
|
+
const confirmButton = await waitFor(() => screen.getByTestId("dlg-confirm"));
|
|
1728
|
+
await waitFor(() => expect(document.activeElement).toBe(confirmButton));
|
|
1729
|
+
await user.keyboard("{Enter}");
|
|
1730
|
+
expect(onConfirm).toHaveBeenCalledTimes(1);
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
test("with children: initial focus stays off Confirm (Radix default, e.g. an input inside)", async () => {
|
|
1734
|
+
render(
|
|
1735
|
+
<Dialog
|
|
1736
|
+
open
|
|
1737
|
+
onOpenChange={() => undefined}
|
|
1738
|
+
title="Details"
|
|
1739
|
+
onConfirm={() => undefined}
|
|
1740
|
+
testId="dlg-with-content"
|
|
1741
|
+
>
|
|
1742
|
+
<input type="text" data-testid="dlg-input" />
|
|
1743
|
+
</Dialog>,
|
|
1744
|
+
);
|
|
1745
|
+
const confirmButton = await waitFor(() => screen.getByTestId("dlg-with-content-confirm"));
|
|
1746
|
+
// No explicit assertion on where focus DOES land (Radix's own default,
|
|
1747
|
+
// out of scope here) — only that it isn't hijacked onto Confirm.
|
|
1748
|
+
expect(document.activeElement).not.toBe(confirmButton);
|
|
1749
|
+
});
|
|
1684
1750
|
});
|
|
1685
1751
|
|
|
1686
1752
|
describe("Text variants", () => {
|
|
@@ -112,6 +112,102 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
|
|
|
112
112
|
).toBe("3");
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
test("metric.navigate — clicking the value navigates to the resolved id and applies params", async () => {
|
|
116
|
+
const navigateCalls: unknown[] = [];
|
|
117
|
+
const setSearchParamsCalls: Readonly<Record<string, string | null>>[] = [];
|
|
118
|
+
const navApi: NavApi = {
|
|
119
|
+
route: undefined,
|
|
120
|
+
navigate: (target) => navigateCalls.push(target),
|
|
121
|
+
replace: () => {},
|
|
122
|
+
hrefFor: () => "",
|
|
123
|
+
searchParams: {},
|
|
124
|
+
setSearchParams: (updates) => setSearchParamsCalls.push(updates),
|
|
125
|
+
};
|
|
126
|
+
const metricsScreen: ProjectionDetailScreenDefinition = {
|
|
127
|
+
...baseScreen,
|
|
128
|
+
metrics: [
|
|
129
|
+
{
|
|
130
|
+
field: "balance",
|
|
131
|
+
label: "rentals.detail.metric.tenant",
|
|
132
|
+
navigate: { entity: "tenant", entityId: "tenantId", params: { pick: ["overdueDays"] } },
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
const dispatcher = dispatcherReturning({ ...rowData, tenantId: "tenant-9" });
|
|
137
|
+
const user = userEvent.setup();
|
|
138
|
+
|
|
139
|
+
render(
|
|
140
|
+
<NavProvider value={navApi}>
|
|
141
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
142
|
+
<KumikoScreen
|
|
143
|
+
schema={schemaFor(metricsScreen)}
|
|
144
|
+
qn="rentals:screen:rent-detail"
|
|
145
|
+
entityId="rent-1"
|
|
146
|
+
/>
|
|
147
|
+
</DispatcherProvider>
|
|
148
|
+
</NavProvider>,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
152
|
+
expect(
|
|
153
|
+
screen.getByTestId("kumiko-screen-projection-detail-metric-balance-label").textContent,
|
|
154
|
+
).toBe("rentals.detail.metric.tenant");
|
|
155
|
+
await user.click(screen.getByTestId("kumiko-screen-projection-detail-metric-balance"));
|
|
156
|
+
|
|
157
|
+
expect(navigateCalls).toEqual([{ entity: "tenant", id: "tenant-9" }]);
|
|
158
|
+
expect(setSearchParamsCalls).toContainEqual({ overdueDays: "3" });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("header.subtitleHref — an absolute http(s) URL renders the subtitle as an external link", async () => {
|
|
162
|
+
const headerScreen: ProjectionDetailScreenDefinition = {
|
|
163
|
+
...baseScreen,
|
|
164
|
+
header: { title: "tenantName", subtitle: "address", subtitleHref: "addressUrl" },
|
|
165
|
+
};
|
|
166
|
+
const dispatcher = dispatcherReturning({
|
|
167
|
+
...rowData,
|
|
168
|
+
addressUrl: "https://maps.example.com/12-canal-st",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
173
|
+
<KumikoScreen
|
|
174
|
+
schema={schemaFor(headerScreen)}
|
|
175
|
+
qn="rentals:screen:rent-detail"
|
|
176
|
+
entityId="rent-1"
|
|
177
|
+
/>
|
|
178
|
+
</DispatcherProvider>,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
182
|
+
const subtitle = screen.getByTestId("kumiko-screen-projection-detail-subtitle");
|
|
183
|
+
expect(subtitle.tagName).toBe("A");
|
|
184
|
+
expect(subtitle.getAttribute("href")).toBe("https://maps.example.com/12-canal-st");
|
|
185
|
+
expect(subtitle.getAttribute("target")).toBe("_blank");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("header.subtitleHref with a non-absolute value keeps the subtitle as plain text", async () => {
|
|
189
|
+
const headerScreen: ProjectionDetailScreenDefinition = {
|
|
190
|
+
...baseScreen,
|
|
191
|
+
header: { title: "tenantName", subtitle: "address", subtitleHref: "addressUrl" },
|
|
192
|
+
};
|
|
193
|
+
const dispatcher = dispatcherReturning({ ...rowData, addressUrl: "/relative/path" });
|
|
194
|
+
|
|
195
|
+
render(
|
|
196
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
197
|
+
<KumikoScreen
|
|
198
|
+
schema={schemaFor(headerScreen)}
|
|
199
|
+
qn="rentals:screen:rent-detail"
|
|
200
|
+
entityId="rent-1"
|
|
201
|
+
/>
|
|
202
|
+
</DispatcherProvider>,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
206
|
+
const subtitle = screen.getByTestId("kumiko-screen-projection-detail-subtitle");
|
|
207
|
+
expect(subtitle.tagName).not.toBe("A");
|
|
208
|
+
expect(subtitle.textContent).toBe("12 Canal St");
|
|
209
|
+
});
|
|
210
|
+
|
|
115
211
|
test("without header/metrics, neither renders — regression protection for existing screens", async () => {
|
|
116
212
|
const dispatcher = dispatcherReturning(rowData);
|
|
117
213
|
|
|
@@ -632,6 +632,108 @@ describe("KumikoScreen / projectionDetail extension section (solon#264)", () =>
|
|
|
632
632
|
expect(screen.getByTestId("session-notes").textContent).toBe("user-session:sess-1");
|
|
633
633
|
expect(screen.queryByTestId("field-userId")).toBeNull();
|
|
634
634
|
});
|
|
635
|
+
|
|
636
|
+
test("layout.mode: 'tabs' with a header — exactly one card renders, the head (bedienkonzept A1)", async () => {
|
|
637
|
+
const tabsHeaderScreen: ProjectionDetailScreenDefinition = {
|
|
638
|
+
...detailScreen,
|
|
639
|
+
header: { title: "userId" },
|
|
640
|
+
layout: {
|
|
641
|
+
mode: "tabs",
|
|
642
|
+
sections: [
|
|
643
|
+
{ id: "overview", title: "Overview", fields: ["userId"] },
|
|
644
|
+
{ id: "meta", title: "Meta", fields: ["createdAt"] },
|
|
645
|
+
],
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
const tabsHeaderSchema: FeatureSchema = {
|
|
649
|
+
featureName: "sessions",
|
|
650
|
+
entities: {},
|
|
651
|
+
screens: [tabsHeaderScreen],
|
|
652
|
+
};
|
|
653
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
654
|
+
query: (async () => ({
|
|
655
|
+
isSuccess: true,
|
|
656
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
|
|
657
|
+
})) as unknown as Dispatcher["query"],
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
const nav: NavApi = {
|
|
661
|
+
route: undefined,
|
|
662
|
+
navigate: () => {},
|
|
663
|
+
replace: () => {},
|
|
664
|
+
hrefFor: () => "",
|
|
665
|
+
searchParams: {},
|
|
666
|
+
setSearchParams: () => {},
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const { container } = render(
|
|
670
|
+
<NavProvider value={nav}>
|
|
671
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
672
|
+
<KumikoScreen
|
|
673
|
+
schema={tabsHeaderSchema}
|
|
674
|
+
qn="sessions:screen:session-detail"
|
|
675
|
+
entityId="sess-1"
|
|
676
|
+
/>
|
|
677
|
+
</DispatcherProvider>
|
|
678
|
+
</NavProvider>,
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
682
|
+
// Bedienkonzept A1: on a tabbed Akte there is exactly one card, the head
|
|
683
|
+
// — tab content sits on the page background. Checked via the card's
|
|
684
|
+
// structural marker, not a Tailwind class string.
|
|
685
|
+
expect(container.querySelectorAll('[data-slot="card"]')).toHaveLength(1);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("layout.mode: 'tabs' with countField — tab label carries the record's count, sections without it stay unchanged", async () => {
|
|
689
|
+
const tabsCountScreen: ProjectionDetailScreenDefinition = {
|
|
690
|
+
...detailScreen,
|
|
691
|
+
layout: {
|
|
692
|
+
mode: "tabs",
|
|
693
|
+
sections: [
|
|
694
|
+
{ id: "overview", title: "Overview", fields: ["userId"], countField: "openCount" },
|
|
695
|
+
{ id: "meta", title: "Meta", fields: ["createdAt"] },
|
|
696
|
+
],
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
const tabsCountSchema: FeatureSchema = {
|
|
700
|
+
featureName: "sessions",
|
|
701
|
+
entities: {},
|
|
702
|
+
screens: [tabsCountScreen],
|
|
703
|
+
};
|
|
704
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
705
|
+
query: (async () => ({
|
|
706
|
+
isSuccess: true,
|
|
707
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z", openCount: 3 },
|
|
708
|
+
})) as unknown as Dispatcher["query"],
|
|
709
|
+
});
|
|
710
|
+
const nav: NavApi = {
|
|
711
|
+
route: undefined,
|
|
712
|
+
navigate: () => {},
|
|
713
|
+
replace: () => {},
|
|
714
|
+
hrefFor: () => "",
|
|
715
|
+
searchParams: {},
|
|
716
|
+
setSearchParams: () => {},
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
render(
|
|
720
|
+
<NavProvider value={nav}>
|
|
721
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
722
|
+
<KumikoScreen
|
|
723
|
+
schema={tabsCountSchema}
|
|
724
|
+
qn="sessions:screen:session-detail"
|
|
725
|
+
entityId="sess-1"
|
|
726
|
+
/>
|
|
727
|
+
</DispatcherProvider>
|
|
728
|
+
</NavProvider>,
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
732
|
+
expect(
|
|
733
|
+
screen.getByTestId("kumiko-screen-projection-detail-tabs-overview-count").textContent,
|
|
734
|
+
).toBe("3");
|
|
735
|
+
expect(screen.queryByTestId("kumiko-screen-projection-detail-tabs-meta-count")).toBeNull();
|
|
736
|
+
});
|
|
635
737
|
});
|
|
636
738
|
|
|
637
739
|
// projectionDetail.actions kind:"drawer" (fw#2710) — same shared DrawerHost
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SidebarPanel tone (fw bedienkonzept point 8): a screen list can carry the
|
|
2
|
+
// page's own content colors instead of always reading as shell navigation.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { render, screen } from "@testing-library/react";
|
|
6
|
+
import { SidebarPanel } from "../sidebar-panel";
|
|
7
|
+
|
|
8
|
+
describe("SidebarPanel — tone", () => {
|
|
9
|
+
test("default (nav) keeps the shell's sidebar chrome classes", () => {
|
|
10
|
+
render(
|
|
11
|
+
<SidebarPanel>
|
|
12
|
+
<div data-testid="child">rows</div>
|
|
13
|
+
</SidebarPanel>,
|
|
14
|
+
);
|
|
15
|
+
const body = screen.getByTestId("child").parentElement;
|
|
16
|
+
expect(body?.className).toContain("bg-sidebar");
|
|
17
|
+
expect(body?.className).toContain("border-sidebar-border");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("tone=surface drops the sidebar classes for content-colored ones", () => {
|
|
21
|
+
render(
|
|
22
|
+
<SidebarPanel tone="surface">
|
|
23
|
+
<div data-testid="child">rows</div>
|
|
24
|
+
</SidebarPanel>,
|
|
25
|
+
);
|
|
26
|
+
const body = screen.getByTestId("child").parentElement;
|
|
27
|
+
expect(body?.className).toContain("bg-muted");
|
|
28
|
+
expect(body?.className).toContain("border-border");
|
|
29
|
+
expect(body?.className).not.toContain("bg-sidebar");
|
|
30
|
+
expect(body?.className).not.toContain("border-sidebar-border");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -52,6 +52,10 @@ export type SidebarPanelProps = {
|
|
|
52
52
|
* two screens with their own list should not overwrite each other's width. */
|
|
53
53
|
readonly storageKey?: string;
|
|
54
54
|
readonly className?: string;
|
|
55
|
+
/** "nav" (default) matches the shell's own sidebar chrome. "surface" reads
|
|
56
|
+
* as page content instead of navigation — for a list that carries the
|
|
57
|
+
* screen's own content colors (e.g. status-tinted rows). */
|
|
58
|
+
readonly tone?: "nav" | "surface";
|
|
55
59
|
};
|
|
56
60
|
|
|
57
61
|
const DEFAULT_WIDTH = 340;
|
|
@@ -79,6 +83,7 @@ export function SidebarPanel({
|
|
|
79
83
|
maxWidth = DEFAULT_MAX,
|
|
80
84
|
storageKey,
|
|
81
85
|
className,
|
|
86
|
+
tone = "nav",
|
|
82
87
|
}: SidebarPanelProps): ReactNode {
|
|
83
88
|
const slot = useSidebarPanelSlot();
|
|
84
89
|
const setOccupied = slot?.setOccupied;
|
|
@@ -134,7 +139,8 @@ export function SidebarPanel({
|
|
|
134
139
|
<div
|
|
135
140
|
data-kumiko-layout="sidebar-panel-body"
|
|
136
141
|
className={cn(
|
|
137
|
-
"relative flex h-full shrink-0 flex-col border-
|
|
142
|
+
"relative flex h-full shrink-0 flex-col border-r",
|
|
143
|
+
tone === "surface" ? "border-border bg-muted" : "border-sidebar-border bg-sidebar",
|
|
138
144
|
className,
|
|
139
145
|
)}
|
|
140
146
|
style={{ width: `${width}px` }}
|
|
@@ -145,7 +151,10 @@ export function SidebarPanel({
|
|
|
145
151
|
<div
|
|
146
152
|
data-kumiko-layout="sidebar-panel-handle"
|
|
147
153
|
onPointerDown={startDrag}
|
|
148
|
-
className=
|
|
154
|
+
className={cn(
|
|
155
|
+
"absolute inset-y-0 right-0 w-1 cursor-col-resize bg-transparent transition-colors",
|
|
156
|
+
tone === "surface" ? "hover:bg-border" : "hover:bg-sidebar-border",
|
|
157
|
+
)}
|
|
149
158
|
/>
|
|
150
159
|
</div>
|
|
151
160
|
);
|
|
@@ -5,7 +5,7 @@ import type { DialogProps } from "@cosmicdrift/kumiko-renderer";
|
|
|
5
5
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
6
6
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
7
7
|
import { Loader2 } from "lucide-react";
|
|
8
|
-
import { type ReactNode, useState } from "react";
|
|
8
|
+
import { type ReactNode, useRef, useState } from "react";
|
|
9
9
|
import { cn } from "../lib/cn";
|
|
10
10
|
import { ModalShell } from "./modal-shell";
|
|
11
11
|
|
|
@@ -23,6 +23,7 @@ export function DefaultDialog({
|
|
|
23
23
|
}: DialogProps): ReactNode {
|
|
24
24
|
const t = useTranslation();
|
|
25
25
|
const [loading, setLoading] = useState(false);
|
|
26
|
+
const confirmButtonRef = useRef<HTMLButtonElement>(null);
|
|
26
27
|
|
|
27
28
|
const effectiveConfirmLabel = confirmLabel ?? t("kumiko.dialog.confirm");
|
|
28
29
|
const effectiveCancelLabel = cancelLabel ?? t("kumiko.dialog.cancel");
|
|
@@ -50,6 +51,14 @@ export function DefaultDialog({
|
|
|
50
51
|
closeLabel={t("kumiko.dialog.close")}
|
|
51
52
|
noAriaDescription={description === undefined}
|
|
52
53
|
contentClassName={cn("grid w-full max-w-lg gap-4 border bg-card p-6 shadow-lg rounded-lg")}
|
|
54
|
+
{...(children === undefined && {
|
|
55
|
+
// Radix's default initial focus lands on Cancel (first focusable in
|
|
56
|
+
// DOM order), so Enter would abort instead of confirm.
|
|
57
|
+
onOpenAutoFocus: (event: Event) => {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
confirmButtonRef.current?.focus();
|
|
60
|
+
},
|
|
61
|
+
})}
|
|
53
62
|
>
|
|
54
63
|
<div className="flex flex-col gap-1.5">
|
|
55
64
|
<DialogPrimitive.Title className="text-lg font-semibold tracking-tight">
|
|
@@ -74,6 +83,7 @@ export function DefaultDialog({
|
|
|
74
83
|
</button>
|
|
75
84
|
</DialogPrimitive.Close>
|
|
76
85
|
<button
|
|
86
|
+
ref={confirmButtonRef}
|
|
77
87
|
type="button"
|
|
78
88
|
onClick={() => void handleConfirm()}
|
|
79
89
|
disabled={loading}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -18,6 +18,8 @@ import type {
|
|
|
18
18
|
DataTableSortDir,
|
|
19
19
|
} from "@cosmicdrift/kumiko-renderer";
|
|
20
20
|
import {
|
|
21
|
+
type ActionMenuItemSpec,
|
|
22
|
+
type ActionOverflowMenuProps,
|
|
21
23
|
type BannerProps,
|
|
22
24
|
type ButtonProps,
|
|
23
25
|
type CardProps,
|
|
@@ -1292,16 +1294,24 @@ function DefaultDataTable({
|
|
|
1292
1294
|
);
|
|
1293
1295
|
}
|
|
1294
1296
|
|
|
1295
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
//
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1297
|
+
// The primary row action is the one that always stays a visible text
|
|
1298
|
+
// button — `edit` if declared, else the first visible action (fw
|
|
1299
|
+
// bedienkonzept L3: "Bearbeiten steht in jeder Zeile, immer, als Text").
|
|
1300
|
+
function primaryRowAction(actions: readonly DataTableRowAction[]): DataTableRowAction | undefined {
|
|
1301
|
+
return actions.find((a) => a.id === "edit") ?? actions[0];
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// RowActionsCell renders the row actions depending on `mode`:
|
|
1305
|
+
// - "adaptive" (default): <=2 visible actions render inline (right-
|
|
1306
|
+
// aligned); >2 render the primary action (see `primaryRowAction`)
|
|
1307
|
+
// inline plus the rest in the kebab dropdown.
|
|
1308
|
+
// - "inline": ALWAYS inline buttons, left-aligned + full-width, even for
|
|
1309
|
+
// >2 (no kebab). `w-full justify-start` pins the first button to the
|
|
1310
|
+
// column's left edge so it sits at the same position across rows
|
|
1311
|
+
// (otherwise it would drift with differently-sized labels).
|
|
1312
|
+
// The isVisible filter runs here; an action hidden for a given row never
|
|
1313
|
+
// reaches render. If every action is hidden, the cell stays empty (no
|
|
1314
|
+
// phantom column).
|
|
1305
1315
|
function RowActionsCell({
|
|
1306
1316
|
row,
|
|
1307
1317
|
actions,
|
|
@@ -1335,7 +1345,14 @@ function RowActionsCell({
|
|
|
1335
1345
|
</div>
|
|
1336
1346
|
);
|
|
1337
1347
|
}
|
|
1338
|
-
|
|
1348
|
+
const primary = primaryRowAction(visible);
|
|
1349
|
+
const rest = visible.filter((a) => a.id !== primary?.id);
|
|
1350
|
+
return (
|
|
1351
|
+
<div className="inline-flex items-center gap-1 justify-end">
|
|
1352
|
+
{primary !== undefined && <RowActionButton row={row} action={primary} />}
|
|
1353
|
+
<RowActionsKebab row={row} actions={rest} />
|
|
1354
|
+
</div>
|
|
1355
|
+
);
|
|
1339
1356
|
}
|
|
1340
1357
|
|
|
1341
1358
|
// Shared trigger-State zwischen Inline-Button + Kebab-Item: busy-Flag
|
|
@@ -1526,6 +1543,52 @@ function RowActionsKebab({
|
|
|
1526
1543
|
);
|
|
1527
1544
|
}
|
|
1528
1545
|
|
|
1546
|
+
// Header/row-actions overflow menu (A7) — same three-dots trigger style as
|
|
1547
|
+
// RowActionsKebab, generic ActionMenuItemSpec items instead of the
|
|
1548
|
+
// DataTableRowAction schema (callers own confirm/danger handling per item).
|
|
1549
|
+
function ActionOverflowMenu({ items, label, testId }: ActionOverflowMenuProps): ReactNode {
|
|
1550
|
+
const [open, setOpen] = useState(false);
|
|
1551
|
+
return (
|
|
1552
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
1553
|
+
<DropdownMenuTrigger asChild>
|
|
1554
|
+
<button
|
|
1555
|
+
type="button"
|
|
1556
|
+
aria-label={label}
|
|
1557
|
+
data-testid={testId ?? "action-overflow-menu-trigger"}
|
|
1558
|
+
className={cn(
|
|
1559
|
+
"inline-flex h-8 w-8 items-center justify-center rounded-sm",
|
|
1560
|
+
"hover:bg-accent text-muted-foreground hover:text-foreground",
|
|
1561
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
1562
|
+
)}
|
|
1563
|
+
>
|
|
1564
|
+
<MoreHorizontal className="size-4" aria-hidden="true" />
|
|
1565
|
+
</button>
|
|
1566
|
+
</DropdownMenuTrigger>
|
|
1567
|
+
<DropdownMenuContent align="end">
|
|
1568
|
+
{items.map((item: ActionMenuItemSpec) => {
|
|
1569
|
+
const resolvedIcon = actionIconFor(item.icon);
|
|
1570
|
+
return (
|
|
1571
|
+
<DropdownMenuItem
|
|
1572
|
+
key={item.id}
|
|
1573
|
+
data-testid={`${testId ?? "action-overflow-menu"}-item-${item.id}`}
|
|
1574
|
+
disabled={item.disabled === true}
|
|
1575
|
+
onSelect={(e) => {
|
|
1576
|
+
e.preventDefault();
|
|
1577
|
+
setOpen(false);
|
|
1578
|
+
item.onSelect();
|
|
1579
|
+
}}
|
|
1580
|
+
className={cn(item.variant === "danger" && "text-destructive focus:text-destructive")}
|
|
1581
|
+
>
|
|
1582
|
+
{resolvedIcon !== undefined && <Icon name={resolvedIcon} className="size-4" />}
|
|
1583
|
+
{item.label}
|
|
1584
|
+
</DropdownMenuItem>
|
|
1585
|
+
);
|
|
1586
|
+
})}
|
|
1587
|
+
</DropdownMenuContent>
|
|
1588
|
+
</DropdownMenu>
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1529
1592
|
// InfiniteSentinel — empty div at the end of the table that uses
|
|
1530
1593
|
// IntersectionObserver to detect when the user scrolls near the list end.
|
|
1531
1594
|
// onReachEnd fires exactly once per "becomes visible" transition; the
|
|
@@ -2159,6 +2222,144 @@ function FormRoot({
|
|
|
2159
2222
|
);
|
|
2160
2223
|
}
|
|
2161
2224
|
|
|
2225
|
+
// Sections wrapper for DefaultForm's card and chromeless layouts alike —
|
|
2226
|
+
// only the card-derived padding on non-section children differs between
|
|
2227
|
+
// them (see `chromeless` doc on FormProps).
|
|
2228
|
+
function FormSections({
|
|
2229
|
+
children,
|
|
2230
|
+
chromeless,
|
|
2231
|
+
stickyActions,
|
|
2232
|
+
fillHeight,
|
|
2233
|
+
}: {
|
|
2234
|
+
readonly children: ReactNode;
|
|
2235
|
+
readonly chromeless: boolean;
|
|
2236
|
+
readonly stickyActions: boolean | undefined;
|
|
2237
|
+
readonly fillHeight: boolean | undefined;
|
|
2238
|
+
}): ReactNode {
|
|
2239
|
+
return (
|
|
2240
|
+
<div
|
|
2241
|
+
className={cn(
|
|
2242
|
+
"flex flex-col",
|
|
2243
|
+
// Section-Children (Auto-UI-Edit) trennt eine Linie ZWISCHEN
|
|
2244
|
+
// ihnen — sie padden sich selbst. Flache Felder (Custom-Screens)
|
|
2245
|
+
// kriegen Padding + Rhythmus, keine Linie zwischen jedem Feld.
|
|
2246
|
+
"[&>section:not(:first-child)]:border-t",
|
|
2247
|
+
!chromeless && "[&>:not(section)]:px-6 [&>:not(section)]:py-3",
|
|
2248
|
+
!chromeless && "[&>:not(section):first-child]:pt-6 [&>:not(section):last-child]:pb-6",
|
|
2249
|
+
// ponytail: fixed footer height is a guess (two wrapped button rows +
|
|
2250
|
+
// safe-area, fw#2528) — widen further if a wizard step's last field
|
|
2251
|
+
// ever renders visibly clipped under three or more wrapped rows.
|
|
2252
|
+
stickyActions === true && "max-sm:pb-32",
|
|
2253
|
+
// Same "no flex-1" reasoning as the card above: this is the
|
|
2254
|
+
// one section allowed to shrink (min-h-0) inside the card, not
|
|
2255
|
+
// one forced to grow past its content.
|
|
2256
|
+
fillHeight === true && "min-h-0",
|
|
2257
|
+
)}
|
|
2258
|
+
>
|
|
2259
|
+
<InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
|
|
2260
|
+
</div>
|
|
2261
|
+
);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// Title/subtitle block shared by DefaultForm's card and chromeless layouts —
|
|
2265
|
+
// the caller-supplied subtitle (screen.description) is independent of
|
|
2266
|
+
// hideSectionTitles (which only blanks per-section titles, see RenderEdit)
|
|
2267
|
+
// and must keep rendering in tabs/chromeless mode, just without card chrome.
|
|
2268
|
+
function FormTitleBlock({
|
|
2269
|
+
title,
|
|
2270
|
+
subtitle,
|
|
2271
|
+
testId,
|
|
2272
|
+
bordered,
|
|
2273
|
+
fillHeight,
|
|
2274
|
+
}: {
|
|
2275
|
+
readonly title: ReactNode;
|
|
2276
|
+
readonly subtitle: ReactNode;
|
|
2277
|
+
readonly testId: string | undefined;
|
|
2278
|
+
readonly bordered: boolean;
|
|
2279
|
+
readonly fillHeight: boolean | undefined;
|
|
2280
|
+
}): ReactNode {
|
|
2281
|
+
if (title === undefined && subtitle === undefined) return null;
|
|
2282
|
+
return (
|
|
2283
|
+
<div
|
|
2284
|
+
className={cn(
|
|
2285
|
+
bordered ? cn(cardHeaderBorder, "px-6 pb-4 pt-5") : "pb-4",
|
|
2286
|
+
fillHeight === true && "shrink-0",
|
|
2287
|
+
)}
|
|
2288
|
+
>
|
|
2289
|
+
{title !== undefined && (
|
|
2290
|
+
<h2
|
|
2291
|
+
data-testid={testId !== undefined ? `${testId}-title` : undefined}
|
|
2292
|
+
className="text-lg font-semibold tracking-tight"
|
|
2293
|
+
>
|
|
2294
|
+
{title}
|
|
2295
|
+
</h2>
|
|
2296
|
+
)}
|
|
2297
|
+
{subtitle !== undefined && (
|
|
2298
|
+
<p
|
|
2299
|
+
data-testid={testId !== undefined ? `${testId}-subtitle` : undefined}
|
|
2300
|
+
className="mt-1 text-sm text-muted-foreground"
|
|
2301
|
+
>
|
|
2302
|
+
{subtitle}
|
|
2303
|
+
</p>
|
|
2304
|
+
)}
|
|
2305
|
+
</div>
|
|
2306
|
+
);
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// Footer wrapper for DefaultForm's card and chromeless layouts alike — only
|
|
2310
|
+
// the card-derived horizontal padding/border differs between them.
|
|
2311
|
+
function FormFooter({
|
|
2312
|
+
actions,
|
|
2313
|
+
secondaryActions,
|
|
2314
|
+
testId,
|
|
2315
|
+
chromeless,
|
|
2316
|
+
stickyActions,
|
|
2317
|
+
fillHeight,
|
|
2318
|
+
}: {
|
|
2319
|
+
readonly actions: ReactNode;
|
|
2320
|
+
readonly secondaryActions: ReactNode;
|
|
2321
|
+
readonly testId: string | undefined;
|
|
2322
|
+
readonly chromeless: boolean;
|
|
2323
|
+
readonly stickyActions: boolean | undefined;
|
|
2324
|
+
readonly fillHeight: boolean | undefined;
|
|
2325
|
+
}): ReactNode {
|
|
2326
|
+
if (actions === undefined && secondaryActions === undefined) return null;
|
|
2327
|
+
return (
|
|
2328
|
+
<div
|
|
2329
|
+
className={cn(
|
|
2330
|
+
"flex flex-col-reverse gap-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-4",
|
|
2331
|
+
!chromeless && "px-[var(--card-padding)]",
|
|
2332
|
+
!chromeless && cardFooterBorder,
|
|
2333
|
+
fillHeight === true && "shrink-0",
|
|
2334
|
+
// Below sm (640px): pin to the viewport bottom instead of normal
|
|
2335
|
+
// flow, so a virtual keyboard shrinking the viewport can't push
|
|
2336
|
+
// this out of reach (fw#1918). `fixed` escapes the card's
|
|
2337
|
+
// `overflow-hidden` (only transform/filter/contain ancestors trap
|
|
2338
|
+
// it, confirmed against AppLayout/SidebarInset — neither sets those).
|
|
2339
|
+
stickyActions === true &&
|
|
2340
|
+
"max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)] max-sm:pb-4",
|
|
2341
|
+
)}
|
|
2342
|
+
>
|
|
2343
|
+
{secondaryActions !== undefined && (
|
|
2344
|
+
<div
|
|
2345
|
+
data-testid={testId !== undefined ? `${testId}-actions-secondary` : undefined}
|
|
2346
|
+
className="flex flex-wrap items-center gap-1 max-sm:[&_button]:text-xs"
|
|
2347
|
+
>
|
|
2348
|
+
{secondaryActions}
|
|
2349
|
+
</div>
|
|
2350
|
+
)}
|
|
2351
|
+
{actions !== undefined && (
|
|
2352
|
+
<div
|
|
2353
|
+
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
2354
|
+
className="flex items-center gap-2 max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto"
|
|
2355
|
+
>
|
|
2356
|
+
{actions}
|
|
2357
|
+
</div>
|
|
2358
|
+
)}
|
|
2359
|
+
</div>
|
|
2360
|
+
);
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2162
2363
|
function DefaultForm({
|
|
2163
2364
|
onSubmit,
|
|
2164
2365
|
children,
|
|
@@ -2171,6 +2372,7 @@ function DefaultForm({
|
|
|
2171
2372
|
stickyActions,
|
|
2172
2373
|
headerRegion,
|
|
2173
2374
|
fillHeight,
|
|
2375
|
+
chromeless,
|
|
2174
2376
|
}: FormProps): ReactNode {
|
|
2175
2377
|
// Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
|
|
2176
2378
|
// der Container trägt Card/Titel selbst, sonst Card-in-Card.
|
|
@@ -2197,6 +2399,65 @@ function DefaultForm({
|
|
|
2197
2399
|
);
|
|
2198
2400
|
}
|
|
2199
2401
|
|
|
2402
|
+
const sections = (
|
|
2403
|
+
<FormSections
|
|
2404
|
+
chromeless={chromeless === true}
|
|
2405
|
+
stickyActions={stickyActions}
|
|
2406
|
+
fillHeight={fillHeight}
|
|
2407
|
+
>
|
|
2408
|
+
{children}
|
|
2409
|
+
</FormSections>
|
|
2410
|
+
);
|
|
2411
|
+
const footer = (
|
|
2412
|
+
<FormFooter
|
|
2413
|
+
actions={actions}
|
|
2414
|
+
secondaryActions={secondaryActions}
|
|
2415
|
+
testId={testId}
|
|
2416
|
+
chromeless={chromeless === true}
|
|
2417
|
+
stickyActions={stickyActions}
|
|
2418
|
+
fillHeight={fillHeight}
|
|
2419
|
+
/>
|
|
2420
|
+
);
|
|
2421
|
+
|
|
2422
|
+
// Tab content (bedienkonzept A1): the head card is the ONLY card on the
|
|
2423
|
+
// screen, so a tabbed projectionDetail's tab content must render as a
|
|
2424
|
+
// sibling of that card, not nest another one around itself — chromeless
|
|
2425
|
+
// skips the card wrapper and its border/padding so sections and footer
|
|
2426
|
+
// sit directly on the page background, same edge as a standalone list
|
|
2427
|
+
// screen. Title/subtitle still render unbordered: RenderEdit's tabs
|
|
2428
|
+
// caller already blanks the per-section title via hideSectionTitles, but
|
|
2429
|
+
// the screen-level subtitle (screen.description) is independent of that
|
|
2430
|
+
// and must survive tabs mode.
|
|
2431
|
+
if (chromeless === true) {
|
|
2432
|
+
return (
|
|
2433
|
+
<FormRoot
|
|
2434
|
+
onSubmit={onSubmit}
|
|
2435
|
+
testId={testId}
|
|
2436
|
+
className={cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0")}
|
|
2437
|
+
>
|
|
2438
|
+
<FormScreenShell
|
|
2439
|
+
{...(width !== undefined && { maxWidth: width })}
|
|
2440
|
+
{...(fillHeight === true && { fillHeight: true })}
|
|
2441
|
+
>
|
|
2442
|
+
{headerRegion !== undefined && (
|
|
2443
|
+
<div className={cn("flex flex-col gap-6 mb-8", fillHeight === true && "shrink-0")}>
|
|
2444
|
+
{headerRegion}
|
|
2445
|
+
</div>
|
|
2446
|
+
)}
|
|
2447
|
+
<FormTitleBlock
|
|
2448
|
+
title={title}
|
|
2449
|
+
subtitle={subtitle}
|
|
2450
|
+
testId={testId}
|
|
2451
|
+
bordered={false}
|
|
2452
|
+
fillHeight={fillHeight}
|
|
2453
|
+
/>
|
|
2454
|
+
{sections}
|
|
2455
|
+
{footer}
|
|
2456
|
+
</FormScreenShell>
|
|
2457
|
+
</FormRoot>
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2200
2461
|
// One card form: title as header (no divider under it), sections divided
|
|
2201
2462
|
// between each other, muted action footer. Shell width defaults to full
|
|
2202
2463
|
// (same chrome as lists); pass width to narrow (auth-adjacent / dense).
|
|
@@ -2216,6 +2477,7 @@ function DefaultForm({
|
|
|
2216
2477
|
</div>
|
|
2217
2478
|
)}
|
|
2218
2479
|
<div
|
|
2480
|
+
data-slot="card"
|
|
2219
2481
|
className={cn(
|
|
2220
2482
|
cardSurface(),
|
|
2221
2483
|
"overflow-hidden",
|
|
@@ -2228,82 +2490,15 @@ function DefaultForm({
|
|
|
2228
2490
|
fillHeight === true && "min-h-0 flex flex-col",
|
|
2229
2491
|
)}
|
|
2230
2492
|
>
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
{title}
|
|
2241
|
-
</h2>
|
|
2242
|
-
)}
|
|
2243
|
-
{subtitle !== undefined && (
|
|
2244
|
-
<p
|
|
2245
|
-
data-testid={testId !== undefined ? `${testId}-subtitle` : undefined}
|
|
2246
|
-
className="mt-1 text-sm text-muted-foreground"
|
|
2247
|
-
>
|
|
2248
|
-
{subtitle}
|
|
2249
|
-
</p>
|
|
2250
|
-
)}
|
|
2251
|
-
</div>
|
|
2252
|
-
)}
|
|
2253
|
-
<div
|
|
2254
|
-
className={cn(
|
|
2255
|
-
"flex flex-col",
|
|
2256
|
-
// Section-Children (Auto-UI-Edit) trennt eine Linie ZWISCHEN
|
|
2257
|
-
// ihnen — sie padden sich selbst. Flache Felder (Custom-Screens)
|
|
2258
|
-
// kriegen Padding + Rhythmus, keine Linie zwischen jedem Feld.
|
|
2259
|
-
"[&>section:not(:first-child)]:border-t",
|
|
2260
|
-
"[&>:not(section)]:px-6 [&>:not(section)]:py-3",
|
|
2261
|
-
"[&>:not(section):first-child]:pt-6 [&>:not(section):last-child]:pb-6",
|
|
2262
|
-
// ponytail: fixed footer height is a guess (two wrapped button rows +
|
|
2263
|
-
// safe-area, fw#2528) — widen further if a wizard step's last field
|
|
2264
|
-
// ever renders visibly clipped under three or more wrapped rows.
|
|
2265
|
-
stickyActions === true && "max-sm:pb-32",
|
|
2266
|
-
// Same "no flex-1" reasoning as the card above: this is the
|
|
2267
|
-
// one section allowed to shrink (min-h-0) inside the card, not
|
|
2268
|
-
// one forced to grow past its content.
|
|
2269
|
-
fillHeight === true && "min-h-0",
|
|
2270
|
-
)}
|
|
2271
|
-
>
|
|
2272
|
-
<InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
|
|
2273
|
-
</div>
|
|
2274
|
-
{(secondaryActions !== undefined || actions !== undefined) && (
|
|
2275
|
-
<div
|
|
2276
|
-
className={cn(
|
|
2277
|
-
"flex flex-col-reverse gap-3 px-[var(--card-padding)] py-3 sm:flex-row sm:items-center sm:justify-between sm:py-4",
|
|
2278
|
-
cardFooterBorder,
|
|
2279
|
-
fillHeight === true && "shrink-0",
|
|
2280
|
-
// Below sm (640px): pin to the viewport bottom instead of normal
|
|
2281
|
-
// flow, so a virtual keyboard shrinking the viewport can't push
|
|
2282
|
-
// this out of reach (fw#1918). `fixed` escapes the card's
|
|
2283
|
-
// `overflow-hidden` (only transform/filter/contain ancestors trap
|
|
2284
|
-
// it, confirmed against AppLayout/SidebarInset — neither sets those).
|
|
2285
|
-
stickyActions === true &&
|
|
2286
|
-
"max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)] max-sm:pb-4",
|
|
2287
|
-
)}
|
|
2288
|
-
>
|
|
2289
|
-
{secondaryActions !== undefined && (
|
|
2290
|
-
<div
|
|
2291
|
-
data-testid={testId !== undefined ? `${testId}-actions-secondary` : undefined}
|
|
2292
|
-
className="flex flex-wrap items-center gap-1 max-sm:[&_button]:text-xs"
|
|
2293
|
-
>
|
|
2294
|
-
{secondaryActions}
|
|
2295
|
-
</div>
|
|
2296
|
-
)}
|
|
2297
|
-
{actions !== undefined && (
|
|
2298
|
-
<div
|
|
2299
|
-
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
2300
|
-
className="flex items-center gap-2 max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto"
|
|
2301
|
-
>
|
|
2302
|
-
{actions}
|
|
2303
|
-
</div>
|
|
2304
|
-
)}
|
|
2305
|
-
</div>
|
|
2306
|
-
)}
|
|
2493
|
+
<FormTitleBlock
|
|
2494
|
+
title={title}
|
|
2495
|
+
subtitle={subtitle}
|
|
2496
|
+
testId={testId}
|
|
2497
|
+
bordered={true}
|
|
2498
|
+
fillHeight={fillHeight}
|
|
2499
|
+
/>
|
|
2500
|
+
{sections}
|
|
2501
|
+
{footer}
|
|
2307
2502
|
</div>
|
|
2308
2503
|
</FormScreenShell>
|
|
2309
2504
|
</FormRoot>
|
|
@@ -2430,6 +2625,7 @@ function DefaultSection({
|
|
|
2430
2625
|
// standalone-section content that renders its own non-portaled overlay.
|
|
2431
2626
|
return (
|
|
2432
2627
|
<div
|
|
2628
|
+
data-slot="card"
|
|
2433
2629
|
data-testid={testId}
|
|
2434
2630
|
className={cn(
|
|
2435
2631
|
cardSurface(),
|
|
@@ -2668,7 +2864,11 @@ export function DefaultCard({ slots, options, className, testId, children }: Car
|
|
|
2668
2864
|
const header = s.header ?? defaultHeader;
|
|
2669
2865
|
const hasHeader = header !== null && header !== undefined;
|
|
2670
2866
|
return (
|
|
2671
|
-
<div
|
|
2867
|
+
<div
|
|
2868
|
+
data-slot="card"
|
|
2869
|
+
data-testid={testId}
|
|
2870
|
+
className={cn(cardSurface({ radius }), "overflow-hidden", className)}
|
|
2871
|
+
>
|
|
2672
2872
|
{header}
|
|
2673
2873
|
{/* != null covers undefined AND explicit null; a `false` child (from
|
|
2674
2874
|
`cond && <El/>`) still renders no visible content either way. */}
|
|
@@ -2721,4 +2921,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
2721
2921
|
Metric: DefaultMetric,
|
|
2722
2922
|
JsonView: DefaultJsonView,
|
|
2723
2923
|
FillContainer: DefaultFillContainer,
|
|
2924
|
+
ActionOverflowMenu,
|
|
2724
2925
|
};
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { MetricProps } from "@cosmicdrift/kumiko-renderer";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
3
4
|
|
|
4
5
|
// Borderless cell in the metrics-band Grid — `first:border-l-0` drops the
|
|
5
6
|
// divider on the first cell purely from DOM position, so the tiles read as
|
|
6
7
|
// one row of vertical dividers instead of individual cards (fw record-
|
|
7
8
|
// screen-type polish). Typo matches StatCard's label/value rhythm.
|
|
8
|
-
export function DefaultMetric({ label, value, testId }: MetricProps): ReactNode {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
export function DefaultMetric({ label, value, testId, onPress }: MetricProps): ReactNode {
|
|
10
|
+
const cellClassName = "border-l first:border-l-0 px-4 py-3";
|
|
11
|
+
const content = (
|
|
12
|
+
<>
|
|
11
13
|
<div
|
|
12
14
|
className="text-xs text-muted-foreground"
|
|
13
15
|
data-testid={testId !== undefined ? `${testId}-label` : undefined}
|
|
@@ -20,6 +22,23 @@ export function DefaultMetric({ label, value, testId }: MetricProps): ReactNode
|
|
|
20
22
|
>
|
|
21
23
|
{value}
|
|
22
24
|
</div>
|
|
25
|
+
</>
|
|
26
|
+
);
|
|
27
|
+
if (onPress !== undefined) {
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
data-testid={testId}
|
|
32
|
+
className={cn(cellClassName, "w-full text-left hover:bg-muted/50")}
|
|
33
|
+
onClick={onPress}
|
|
34
|
+
>
|
|
35
|
+
{content}
|
|
36
|
+
</button>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return (
|
|
40
|
+
<div data-testid={testId} className={cellClassName}>
|
|
41
|
+
{content}
|
|
23
42
|
</div>
|
|
24
43
|
);
|
|
25
44
|
}
|
|
@@ -16,6 +16,9 @@ export type ModalShellProps = {
|
|
|
16
16
|
readonly noAriaDescription?: boolean;
|
|
17
17
|
readonly closeLabel?: string;
|
|
18
18
|
readonly showCloseButton?: boolean;
|
|
19
|
+
/** Lets the caller take over Radix's initial-focus target — call
|
|
20
|
+
* `event.preventDefault()` and focus the desired element yourself. */
|
|
21
|
+
readonly onOpenAutoFocus?: (event: Event) => void;
|
|
19
22
|
};
|
|
20
23
|
|
|
21
24
|
export function ModalShell({
|
|
@@ -27,6 +30,7 @@ export function ModalShell({
|
|
|
27
30
|
noAriaDescription,
|
|
28
31
|
closeLabel,
|
|
29
32
|
showCloseButton = true,
|
|
33
|
+
onOpenAutoFocus,
|
|
30
34
|
}: ModalShellProps): ReactNode {
|
|
31
35
|
return (
|
|
32
36
|
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
|
@@ -35,6 +39,7 @@ export function ModalShell({
|
|
|
35
39
|
<DialogPrimitive.Content
|
|
36
40
|
data-testid={testId}
|
|
37
41
|
{...(noAriaDescription && { "aria-describedby": undefined })}
|
|
42
|
+
{...(onOpenAutoFocus !== undefined && { onOpenAutoFocus })}
|
|
38
43
|
className={cn(
|
|
39
44
|
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
|
|
40
45
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
package/src/primitives/tabs.tsx
CHANGED
|
@@ -15,6 +15,14 @@ export function DefaultTabs({ items, activeId, onSelect, testId }: TabsProps): R
|
|
|
15
15
|
data-testid={testId !== undefined ? `${testId}-${item.id}` : undefined}
|
|
16
16
|
>
|
|
17
17
|
{item.label}
|
|
18
|
+
{item.count !== undefined && (
|
|
19
|
+
<span
|
|
20
|
+
className="ml-1.5 text-muted-foreground"
|
|
21
|
+
data-testid={testId !== undefined ? `${testId}-${item.id}-count` : undefined}
|
|
22
|
+
>
|
|
23
|
+
{item.count}
|
|
24
|
+
</span>
|
|
25
|
+
)}
|
|
18
26
|
</TabsTrigger>
|
|
19
27
|
))}
|
|
20
28
|
</TabsList>
|