@cosmicdrift/kumiko-renderer-web 0.170.0 → 0.171.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 -4
- package/src/__tests__/combobox.test.tsx +43 -0
- package/src/__tests__/primitives.test.tsx +47 -1
- package/src/__tests__/projection-detail.test.tsx +30 -0
- package/src/__tests__/reference-create.test.tsx +181 -0
- package/src/__tests__/render-edit.test.tsx +77 -0
- package/src/app/create-app.tsx +23 -20
- package/src/index.ts +1 -0
- package/src/layout/nav-tree.tsx +2 -0
- package/src/primitives/combobox.tsx +26 -1
- package/src/primitives/index.tsx +74 -8
- package/src/primitives/modal.tsx +39 -0
- package/src/ui/resizable.tsx +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.171.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.171.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.171.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.171.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",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"react": "^19.2.6",
|
|
37
37
|
"react-day-picker": "^10.0.0",
|
|
38
38
|
"react-dom": "^19.2.6",
|
|
39
|
+
"react-resizable-panels": "^4.12.2",
|
|
39
40
|
"tailwind-merge": "^3.6.0",
|
|
40
41
|
"temporal-polyfill": "^0.3.2"
|
|
41
42
|
},
|
|
@@ -236,4 +236,47 @@ describe("ComboboxInput (Tier 2.1c)", () => {
|
|
|
236
236
|
);
|
|
237
237
|
expect(screen.getByTestId("combobox-combo").getAttribute("aria-invalid")).toBe("true");
|
|
238
238
|
});
|
|
239
|
+
|
|
240
|
+
// #1681: "+ Create" must stay visible even with zero matching options —
|
|
241
|
+
// that's the most common case (a freshly seeded system has no
|
|
242
|
+
// referenceable rows yet), not the edge case. The footer deliberately
|
|
243
|
+
// lives OUTSIDE Command.List/cmdk's filter+empty machinery so it never
|
|
244
|
+
// competes with it for visibility.
|
|
245
|
+
test("onCreate: Footer bleibt sichtbar wenn keine Optionen matchen, klick feuert onCreate + schließt Popover", async () => {
|
|
246
|
+
const user = userEvent.setup();
|
|
247
|
+
let created = 0;
|
|
248
|
+
render(
|
|
249
|
+
<ComboboxInput
|
|
250
|
+
id="combo"
|
|
251
|
+
name="combo"
|
|
252
|
+
value=""
|
|
253
|
+
onChange={() => {}}
|
|
254
|
+
options={[]}
|
|
255
|
+
onCreate={() => {
|
|
256
|
+
created += 1;
|
|
257
|
+
}}
|
|
258
|
+
createLabel="Neuen Kontakt anlegen"
|
|
259
|
+
defaultOpen
|
|
260
|
+
/>,
|
|
261
|
+
);
|
|
262
|
+
const createButton = await screen.findByTestId("combobox-combo-create");
|
|
263
|
+
expect(createButton.textContent).toContain("Neuen Kontakt anlegen");
|
|
264
|
+
await user.click(createButton);
|
|
265
|
+
expect(created).toBe(1);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("ohne onCreate: kein Footer-Item im Popover", async () => {
|
|
269
|
+
render(
|
|
270
|
+
<ComboboxInput
|
|
271
|
+
id="combo"
|
|
272
|
+
name="combo"
|
|
273
|
+
value=""
|
|
274
|
+
onChange={() => {}}
|
|
275
|
+
options={[{ value: "a", label: "Alpha" }]}
|
|
276
|
+
defaultOpen
|
|
277
|
+
/>,
|
|
278
|
+
);
|
|
279
|
+
await screen.findByText("Alpha");
|
|
280
|
+
expect(screen.queryByTestId("combobox-combo-create")).toBeNull();
|
|
281
|
+
});
|
|
239
282
|
});
|
|
@@ -166,6 +166,30 @@ describe("Input kind mapping", () => {
|
|
|
166
166
|
render(<Input id="i" name="i" kind="text" value="" hasError onChange={() => {}} />);
|
|
167
167
|
expect(screen.getByRole("textbox").getAttribute("aria-invalid")).toBe("true");
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
test('kind="text" icon="mail": renders prefix icon + pads the input', () => {
|
|
171
|
+
render(<Input id="i" name="i" kind="text" value="" icon="mail" onChange={() => {}} />);
|
|
172
|
+
const input = screen.getByRole("textbox");
|
|
173
|
+
expect(input.className).toContain("pl-8");
|
|
174
|
+
expect(document.querySelector("svg[aria-hidden='true']")).not.toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('kind="text" icon="not-a-real-key": unknown key → no icon, no padding', () => {
|
|
178
|
+
render(
|
|
179
|
+
<Input id="i" name="i" kind="text" value="" icon="not-a-real-key" onChange={() => {}} />,
|
|
180
|
+
);
|
|
181
|
+
const input = screen.getByRole("textbox");
|
|
182
|
+
expect(input.className).not.toContain("pl-8");
|
|
183
|
+
expect(document.querySelector("svg[aria-hidden='true']")).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('kind="number" icon="hash": renders prefix icon alongside existing right-align classes', () => {
|
|
187
|
+
render(<Input id="i" name="i" kind="number" value={0} icon="hash" onChange={() => {}} />);
|
|
188
|
+
const input = screen.getByRole("spinbutton");
|
|
189
|
+
expect(input.className).toContain("pl-8");
|
|
190
|
+
expect(input.className).toContain("text-right");
|
|
191
|
+
expect(document.querySelector("svg[aria-hidden='true']")).not.toBeNull();
|
|
192
|
+
});
|
|
169
193
|
});
|
|
170
194
|
|
|
171
195
|
describe("DataTable", () => {
|
|
@@ -447,7 +471,8 @@ describe("DataTable", () => {
|
|
|
447
471
|
});
|
|
448
472
|
|
|
449
473
|
// Infinite-Scroll Sentinel: rendert sentinel-div, zeigt Spinner wenn
|
|
450
|
-
// loadingMore,
|
|
474
|
+
// loadingMore, den i18n End-of-list-Marker (kumiko.list.end-of-list)
|
|
475
|
+
// wenn !hasMore. IntersectionObserver
|
|
451
476
|
// selbst ist in jsdom unmocked — wir testen nur die Marker, der
|
|
452
477
|
// Observer-Fire-Pfad ist im KumikoScreen.EntityListBody.
|
|
453
478
|
describe("InfiniteSentinel", () => {
|
|
@@ -505,6 +530,27 @@ describe("DataTable", () => {
|
|
|
505
530
|
expect(screen.getByTestId("dt-sentinel-end")).not.toBeNull();
|
|
506
531
|
expect(screen.getByTestId("dt-sentinel-end").textContent).toContain("End of list");
|
|
507
532
|
});
|
|
533
|
+
|
|
534
|
+
test("hasMore=false + de-Locale: Marker kommt aus i18n statt hartcodiert", async () => {
|
|
535
|
+
const { LocaleProvider, createStaticLocaleResolver, kumikoDefaultTranslations } =
|
|
536
|
+
await import("@cosmicdrift/kumiko-renderer");
|
|
537
|
+
render(
|
|
538
|
+
<LocaleProvider
|
|
539
|
+
resolver={createStaticLocaleResolver({ locale: "de" })}
|
|
540
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
541
|
+
>
|
|
542
|
+
<DataTable
|
|
543
|
+
columns={cols}
|
|
544
|
+
rows={oneRow}
|
|
545
|
+
testId="dt"
|
|
546
|
+
onReachEnd={mock()}
|
|
547
|
+
loadingMore={false}
|
|
548
|
+
hasMore={false}
|
|
549
|
+
/>
|
|
550
|
+
</LocaleProvider>,
|
|
551
|
+
);
|
|
552
|
+
expect(screen.getByTestId("dt-sentinel-end").textContent).toBe("— Ende der Liste —");
|
|
553
|
+
});
|
|
508
554
|
});
|
|
509
555
|
|
|
510
556
|
// RowActions: pinst die Inline-vs-Kebab-Entscheidung, Confirm-Dialog
|
|
@@ -59,6 +59,36 @@ describe("KumikoScreen / projectionDetail", () => {
|
|
|
59
59
|
expect(screen.queryByTestId("render-edit-submit")).toBeNull();
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
// synthesizeProjectionDetailScreen rebuilds `layout` from `sections` alone
|
|
63
|
+
// (structural readOnly:true proof) — a naive rebuild would drop sibling
|
|
64
|
+
// layout fields like `width` (#1676).
|
|
65
|
+
test("layout.width survives the projectionDetail → entityEdit shim", async () => {
|
|
66
|
+
const wideDetailScreen: ProjectionDetailScreenDefinition = {
|
|
67
|
+
...detailScreen,
|
|
68
|
+
layout: { ...detailScreen.layout, width: "full" },
|
|
69
|
+
};
|
|
70
|
+
const wideSchema: FeatureSchema = {
|
|
71
|
+
featureName: "sessions",
|
|
72
|
+
entities: {},
|
|
73
|
+
screens: [wideDetailScreen],
|
|
74
|
+
};
|
|
75
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
76
|
+
query: (async () => ({
|
|
77
|
+
isSuccess: true,
|
|
78
|
+
data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
|
|
79
|
+
})) as unknown as Dispatcher["query"],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
render(
|
|
83
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
84
|
+
<KumikoScreen schema={wideSchema} qn="sessions:screen:session-detail" entityId="sess-1" />
|
|
85
|
+
</DispatcherProvider>,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const form = await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
89
|
+
expect(form.firstElementChild?.className).toContain("max-w-full");
|
|
90
|
+
});
|
|
91
|
+
|
|
62
92
|
test("missing entityId shows an error banner instead of crashing", async () => {
|
|
63
93
|
let resolveQuery: (value: unknown) => void = () => {};
|
|
64
94
|
const dispatcher: Dispatcher = createMockDispatcher({
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// #1681: a reference field can create a missing target record right from
|
|
2
|
+
// the combobox. Covers the full chain in one test — every link (screen
|
|
3
|
+
// resolution across features, dialog host, create dispatch, returning the
|
|
4
|
+
// new id, refetching the lookup list) fails the test if it breaks.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type {
|
|
8
|
+
EntityDefinition,
|
|
9
|
+
EntityEditScreenDefinition,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
11
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
12
|
+
import {
|
|
13
|
+
AppFeaturesProvider,
|
|
14
|
+
DispatcherProvider,
|
|
15
|
+
type FeatureSchema,
|
|
16
|
+
KumikoScreen,
|
|
17
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
18
|
+
import userEvent from "@testing-library/user-event";
|
|
19
|
+
import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
|
|
20
|
+
|
|
21
|
+
const taskEntity = {
|
|
22
|
+
fields: {
|
|
23
|
+
title: { type: "text", required: true },
|
|
24
|
+
assignee: { type: "reference", entity: "catalog:widget" },
|
|
25
|
+
tags: { type: "reference", entity: "catalog:widget", multiple: true },
|
|
26
|
+
},
|
|
27
|
+
} as unknown as EntityDefinition;
|
|
28
|
+
|
|
29
|
+
const taskEditScreen: EntityEditScreenDefinition = {
|
|
30
|
+
id: "task-edit",
|
|
31
|
+
type: "entityEdit",
|
|
32
|
+
entity: "task",
|
|
33
|
+
layout: { sections: [{ title: "Basics", fields: ["title", "assignee", "tags"] }] },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const tasksSchema: FeatureSchema = {
|
|
37
|
+
featureName: "tasks",
|
|
38
|
+
entities: { task: taskEntity },
|
|
39
|
+
screens: [taskEditScreen],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const widgetEntity = {
|
|
43
|
+
fields: { name: { type: "text", required: true } },
|
|
44
|
+
} as unknown as EntityDefinition;
|
|
45
|
+
|
|
46
|
+
const widgetCreateScreen: EntityEditScreenDefinition = {
|
|
47
|
+
id: "widget-edit",
|
|
48
|
+
type: "entityEdit",
|
|
49
|
+
entity: "widget",
|
|
50
|
+
layout: { sections: [{ title: "Basics", fields: ["name"] }] },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const catalogSchema: FeatureSchema = {
|
|
54
|
+
featureName: "catalog",
|
|
55
|
+
entities: { widget: widgetEntity },
|
|
56
|
+
screens: [widgetCreateScreen],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function makeDispatcher(): Dispatcher & { readonly writes: string[] } {
|
|
60
|
+
const writes: string[] = [];
|
|
61
|
+
// The lookup query only knows about newly created widgets AFTER the
|
|
62
|
+
// create-write — pins the refetch: without it, the options list (and
|
|
63
|
+
// thus the label of the newly selected value) would stay empty.
|
|
64
|
+
const createdIds: string[] = [];
|
|
65
|
+
const dispatcher = createMockDispatcher({
|
|
66
|
+
query: (async () => ({
|
|
67
|
+
isSuccess: true,
|
|
68
|
+
data: { rows: createdIds.map((id) => ({ id })) },
|
|
69
|
+
})) as unknown as Dispatcher["query"],
|
|
70
|
+
write: (async (type: string) => {
|
|
71
|
+
writes.push(type);
|
|
72
|
+
const id = `widget-${createdIds.length + 1}`;
|
|
73
|
+
createdIds.push(id);
|
|
74
|
+
return { isSuccess: true, data: { kind: "save", id } };
|
|
75
|
+
}) as unknown as Dispatcher["write"],
|
|
76
|
+
});
|
|
77
|
+
return { ...dispatcher, writes };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("Reference-field create-in-place (#1681)", () => {
|
|
81
|
+
test("+ Create öffnet den Create-Screen des Zielfeatures, Submit wählt die neue id + refetcht die Liste", async () => {
|
|
82
|
+
const user = userEvent.setup();
|
|
83
|
+
const dispatcher = makeDispatcher();
|
|
84
|
+
|
|
85
|
+
render(
|
|
86
|
+
<AppFeaturesProvider features={[tasksSchema, catalogSchema]}>
|
|
87
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
88
|
+
<KumikoScreen schema={tasksSchema} qn="tasks:screen:task-edit" />
|
|
89
|
+
</DispatcherProvider>
|
|
90
|
+
</AppFeaturesProvider>,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
94
|
+
await user.click(screen.getByTestId("combobox-kumiko-edit-assignee"));
|
|
95
|
+
const createButton = await screen.findByTestId("combobox-kumiko-edit-assignee-create");
|
|
96
|
+
await user.click(createButton);
|
|
97
|
+
|
|
98
|
+
// The "widget" create-dialog is mounted — its own form field ("name")
|
|
99
|
+
// is visible. Two "render-edit-submit" buttons now exist (the host
|
|
100
|
+
// form behind it + the dialog form) — the dialog portal mounts last,
|
|
101
|
+
// so its button is the last one in the DOM.
|
|
102
|
+
const nameInput = (await screen.findByTestId("field-name")).querySelector("input");
|
|
103
|
+
if (!nameInput) throw new Error("expected name input in create dialog");
|
|
104
|
+
await user.type(nameInput, "Widget A");
|
|
105
|
+
const submitButtons = screen.getAllByTestId("render-edit-submit");
|
|
106
|
+
await user.click(submitButtons[submitButtons.length - 1] as HTMLElement);
|
|
107
|
+
|
|
108
|
+
// Dispatch went to the TARGET feature's ("catalog") qualified create
|
|
109
|
+
// handler, not the host feature's ("tasks").
|
|
110
|
+
await waitFor(() => expect(dispatcher.writes.length).toBe(1));
|
|
111
|
+
expect(dispatcher.writes[0]).toBe("catalog:write:widget:create");
|
|
112
|
+
|
|
113
|
+
// Dialog closes, the reference field shows the newly created id as
|
|
114
|
+
// the selected value — the trigger text is the id (no labelField set
|
|
115
|
+
// → fallback label = id, see ReferenceInput.options).
|
|
116
|
+
await waitFor(() => expect(screen.queryByTestId("field-name")).toBeNull());
|
|
117
|
+
expect(screen.getByTestId("combobox-kumiko-edit-assignee").textContent).toContain("widget-1");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// multi-mode's handleCreated appends to a value it reads from the
|
|
121
|
+
// `field.value` closure — pins that the SECOND create doesn't drop the
|
|
122
|
+
// first id (stale closure would overwrite instead of append).
|
|
123
|
+
test("multi-mode: zwei Creates hintereinander behalten beide ids", async () => {
|
|
124
|
+
const user = userEvent.setup();
|
|
125
|
+
const dispatcher = makeDispatcher();
|
|
126
|
+
|
|
127
|
+
render(
|
|
128
|
+
<AppFeaturesProvider features={[tasksSchema, catalogSchema]}>
|
|
129
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
130
|
+
<KumikoScreen schema={tasksSchema} qn="tasks:screen:task-edit" />
|
|
131
|
+
</DispatcherProvider>
|
|
132
|
+
</AppFeaturesProvider>,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
136
|
+
|
|
137
|
+
for (const expectedId of ["widget-1", "widget-2"]) {
|
|
138
|
+
await user.click(screen.getByTestId("combobox-kumiko-edit-tags"));
|
|
139
|
+
const createButton = await screen.findByTestId("combobox-kumiko-edit-tags-create");
|
|
140
|
+
await user.click(createButton);
|
|
141
|
+
const nameInput = (await screen.findByTestId("field-name")).querySelector("input");
|
|
142
|
+
if (!nameInput) throw new Error("expected name input in create dialog");
|
|
143
|
+
await user.type(nameInput, `Widget ${expectedId}`);
|
|
144
|
+
const submitButtons = screen.getAllByTestId("render-edit-submit");
|
|
145
|
+
await user.click(submitButtons[submitButtons.length - 1] as HTMLElement);
|
|
146
|
+
await waitFor(() => expect(screen.queryByTestId("field-name")).toBeNull());
|
|
147
|
+
await waitFor(() =>
|
|
148
|
+
expect(screen.getByTestId("combobox-kumiko-edit-tags").textContent).toContain(expectedId),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
expect(screen.getByTestId("combobox-kumiko-edit-tags").textContent).toContain("widget-1");
|
|
153
|
+
expect(screen.getByTestId("combobox-kumiko-edit-tags").textContent).toContain("widget-2");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("Cancel im Create-Dialog schließt ohne write, Reference-Feld bleibt leer", async () => {
|
|
157
|
+
const user = userEvent.setup();
|
|
158
|
+
const dispatcher = makeDispatcher();
|
|
159
|
+
|
|
160
|
+
render(
|
|
161
|
+
<AppFeaturesProvider features={[tasksSchema, catalogSchema]}>
|
|
162
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
163
|
+
<KumikoScreen schema={tasksSchema} qn="tasks:screen:task-edit" />
|
|
164
|
+
</DispatcherProvider>
|
|
165
|
+
</AppFeaturesProvider>,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
169
|
+
await user.click(screen.getByTestId("combobox-kumiko-edit-assignee"));
|
|
170
|
+
const createButton = await screen.findByTestId("combobox-kumiko-edit-assignee-create");
|
|
171
|
+
await user.click(createButton);
|
|
172
|
+
await screen.findByTestId("field-name");
|
|
173
|
+
|
|
174
|
+
const cancelButtons = screen.getAllByTestId("render-edit-cancel");
|
|
175
|
+
await user.click(cancelButtons[cancelButtons.length - 1] as HTMLElement);
|
|
176
|
+
|
|
177
|
+
await waitFor(() => expect(screen.queryByTestId("field-name")).toBeNull());
|
|
178
|
+
expect(dispatcher.writes).toHaveLength(0);
|
|
179
|
+
expect(screen.getByTestId("combobox-kumiko-edit-assignee").textContent).not.toContain("widget");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -85,6 +85,46 @@ describe("RenderEdit", () => {
|
|
|
85
85
|
expect(screen.queryByTestId("field-notes")).toBeNull();
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
// Issue #1677: a section's optional `description` renders as the
|
|
89
|
+
// Section's subtitle slot underneath the block heading, and a field's
|
|
90
|
+
// `icon` reaches the DOM as a prefix icon on its input.
|
|
91
|
+
test("section.description renders as the section subtitle; field.icon renders a prefix icon", () => {
|
|
92
|
+
const entity = {
|
|
93
|
+
fields: { email: { type: "text", required: true } },
|
|
94
|
+
} as unknown as EntityDefinition;
|
|
95
|
+
const screenDef: EntityEditScreenDefinition = {
|
|
96
|
+
id: "orders:screen:order-edit",
|
|
97
|
+
type: "entityEdit",
|
|
98
|
+
entity: "order",
|
|
99
|
+
layout: {
|
|
100
|
+
sections: [
|
|
101
|
+
{
|
|
102
|
+
title: "Contact",
|
|
103
|
+
description: "How we'll reach you.",
|
|
104
|
+
columns: 1,
|
|
105
|
+
fields: [{ field: "email", icon: "mail" }],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
render(
|
|
111
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
112
|
+
<RenderEdit
|
|
113
|
+
screen={screenDef}
|
|
114
|
+
entity={entity}
|
|
115
|
+
featureName="orders"
|
|
116
|
+
initial={{ email: "" } as never}
|
|
117
|
+
writeCommand="order:create"
|
|
118
|
+
/>
|
|
119
|
+
</DispatcherProvider>,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(screen.getByTestId("section-Contact-subtitle").textContent).toBe("How we'll reach you.");
|
|
123
|
+
const fieldEl = screen.getByTestId("field-email");
|
|
124
|
+
expect(fieldEl.querySelector("svg[aria-hidden='true']")).not.toBeNull();
|
|
125
|
+
expect(fieldEl.querySelector("input")?.className).toContain("pl-8");
|
|
126
|
+
});
|
|
127
|
+
|
|
88
128
|
// End-to-end-Routing: ein `type:"locatedTimestamp"`-Entity-Feld muss durch
|
|
89
129
|
// computeEditViewModel → render-field → DefaultInput auf den Located-Picker
|
|
90
130
|
// laufen (Datum + Uhrzeit + Zone), NICHT auf den Klartext-Fallthrough. Vor
|
|
@@ -203,6 +243,43 @@ describe("RenderEdit", () => {
|
|
|
203
243
|
expect(seenResults[0]?.isSuccess).toBe(true);
|
|
204
244
|
});
|
|
205
245
|
|
|
246
|
+
test("layout.width defaults the form shell to max-w-3xl when unset", () => {
|
|
247
|
+
render(
|
|
248
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
249
|
+
<RenderEdit<TestValues>
|
|
250
|
+
screen={makeScreen()}
|
|
251
|
+
entity={orderEntity}
|
|
252
|
+
featureName="orders"
|
|
253
|
+
initial={{ title: "", count: 0, isUrgent: false }}
|
|
254
|
+
writeCommand="order:create"
|
|
255
|
+
/>
|
|
256
|
+
</DispatcherProvider>,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const shell = screen.getByTestId("render-edit-form").firstElementChild;
|
|
260
|
+
expect(shell?.className).toContain("max-w-3xl");
|
|
261
|
+
expect(shell?.className).not.toContain("max-w-full");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("layout.width: 'full' widens the form shell to max-w-full (#1676)", () => {
|
|
265
|
+
const screenDef = makeScreen();
|
|
266
|
+
render(
|
|
267
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
268
|
+
<RenderEdit<TestValues>
|
|
269
|
+
screen={{ ...screenDef, layout: { ...screenDef.layout, width: "full" } }}
|
|
270
|
+
entity={orderEntity}
|
|
271
|
+
featureName="orders"
|
|
272
|
+
initial={{ title: "", count: 0, isUrgent: false }}
|
|
273
|
+
writeCommand="order:create"
|
|
274
|
+
/>
|
|
275
|
+
</DispatcherProvider>,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const shell = screen.getByTestId("render-edit-form").firstElementChild;
|
|
279
|
+
expect(shell?.className).toContain("max-w-full");
|
|
280
|
+
expect(shell?.className).not.toContain("max-w-3xl");
|
|
281
|
+
});
|
|
282
|
+
|
|
206
283
|
test("title resolved aus i18n-Key `screen:<id>.title` mit screenId als Fallback", () => {
|
|
207
284
|
const dispatcher = makeDispatcher();
|
|
208
285
|
render(
|
package/src/app/create-app.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
Translate,
|
|
8
8
|
} from "@cosmicdrift/kumiko-headless";
|
|
9
9
|
import {
|
|
10
|
+
AppFeaturesProvider,
|
|
10
11
|
type AppSchema,
|
|
11
12
|
type ColumnRendererComponent,
|
|
12
13
|
ColumnRenderersProvider,
|
|
@@ -328,26 +329,28 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
328
329
|
<TokensBoot>
|
|
329
330
|
<LocaleProvider resolver={localeResolver} fallbackBundles={fallbackBundles}>
|
|
330
331
|
<PrimitivesProvider value={primitives}>
|
|
331
|
-
<
|
|
332
|
-
<
|
|
333
|
-
<
|
|
334
|
-
<
|
|
335
|
-
<
|
|
336
|
-
<
|
|
337
|
-
<
|
|
338
|
-
<
|
|
339
|
-
<
|
|
340
|
-
<
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
332
|
+
<AppFeaturesProvider features={app.features}>
|
|
333
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
334
|
+
<LiveEventsProvider value={liveEvents}>
|
|
335
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
336
|
+
<CustomScreensProvider value={customScreens}>
|
|
337
|
+
<ColumnRenderersProvider value={columnRenderers}>
|
|
338
|
+
<ExtensionSectionsProvider value={extensionSectionComponents}>
|
|
339
|
+
<NavProvidersProvider value={navProviders} entities={navEntities}>
|
|
340
|
+
<ResolversProvider resolvers={resolvers}>
|
|
341
|
+
<ToastProvider>
|
|
342
|
+
<UpdateChecker />
|
|
343
|
+
{stackWrappers(providers, stackWrappers(gates, screenNode))}
|
|
344
|
+
</ToastProvider>
|
|
345
|
+
</ResolversProvider>
|
|
346
|
+
</NavProvidersProvider>
|
|
347
|
+
</ExtensionSectionsProvider>
|
|
348
|
+
</ColumnRenderersProvider>
|
|
349
|
+
</CustomScreensProvider>
|
|
350
|
+
</DashboardBodyProvider>
|
|
351
|
+
</LiveEventsProvider>
|
|
352
|
+
</DispatcherProvider>
|
|
353
|
+
</AppFeaturesProvider>
|
|
351
354
|
</PrimitivesProvider>
|
|
352
355
|
</LocaleProvider>
|
|
353
356
|
</TokensBoot>
|
package/src/index.ts
CHANGED
|
@@ -156,6 +156,7 @@ export {
|
|
|
156
156
|
lightTokens,
|
|
157
157
|
useBrowserTokensApi,
|
|
158
158
|
} from "./tokens";
|
|
159
|
+
export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable";
|
|
159
160
|
export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
|
|
160
161
|
export type {
|
|
161
162
|
AiTextAreaProps,
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
KeyRound,
|
|
49
49
|
Layers,
|
|
50
50
|
LayoutDashboard,
|
|
51
|
+
LayoutGrid,
|
|
51
52
|
LineChart,
|
|
52
53
|
Link,
|
|
53
54
|
List,
|
|
@@ -107,6 +108,7 @@ import { parseTargetFromSearchParams } from "./target-url";
|
|
|
107
108
|
// Keys; Erweiterung = neuer Eintrag hier (eine Quelle, alle Apps).
|
|
108
109
|
const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
|
|
109
110
|
dashboard: LayoutDashboard,
|
|
111
|
+
"layout-grid": LayoutGrid,
|
|
110
112
|
gauge: Gauge,
|
|
111
113
|
list: List,
|
|
112
114
|
table: Table,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { REFERENCE_SEARCH_DEBOUNCE_MS, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
17
17
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
18
18
|
import { Command } from "cmdk";
|
|
19
|
-
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
|
19
|
+
import { Check, ChevronDown, Loader2, Plus } from "lucide-react";
|
|
20
20
|
import { type ReactNode, useEffect, useState } from "react";
|
|
21
21
|
import { cn } from "../lib/cn";
|
|
22
22
|
|
|
@@ -44,6 +44,14 @@ type ComboboxBaseProps = {
|
|
|
44
44
|
readonly onSearchChange?: (q: string) => void;
|
|
45
45
|
/** Spinner im Trigger + Popover-Footer wenn remote search läuft. */
|
|
46
46
|
readonly loading?: boolean;
|
|
47
|
+
/** Fixed footer row below the option list ("+ createLabel"). Rendered
|
|
48
|
+
* unfiltered — stays visible even when the search matches nothing,
|
|
49
|
+
* which is the most common case for reference-lookups on a freshly
|
|
50
|
+
* seeded system (kumiko-framework#1681). Omit for the plain
|
|
51
|
+
* select-existing combobox. */
|
|
52
|
+
readonly onCreate?: () => void;
|
|
53
|
+
/** Label for the create-footer row. Default: i18n `kumiko.actions.create`. */
|
|
54
|
+
readonly createLabel?: string;
|
|
47
55
|
/** Test-Hook: forciert den initial open-state des Popovers. In
|
|
48
56
|
* jsdom + Radix-Popover triggert userEvent.click auf den Trigger
|
|
49
57
|
* nicht zuverlässig PointerEvents — Tests setzen defaultOpen=true,
|
|
@@ -109,6 +117,8 @@ export function ComboboxInput(props: ComboboxInputProps): ReactNode {
|
|
|
109
117
|
onSearchChange,
|
|
110
118
|
loading,
|
|
111
119
|
defaultOpen,
|
|
120
|
+
onCreate,
|
|
121
|
+
createLabel,
|
|
112
122
|
} = props;
|
|
113
123
|
// i18n-Defaults aus dem Framework-Bundle (kumikoDefaultTranslations).
|
|
114
124
|
// Caller-Override gewinnt; Bundle-Override greift wenn der Caller
|
|
@@ -118,6 +128,7 @@ export function ComboboxInput(props: ComboboxInputProps): ReactNode {
|
|
|
118
128
|
const effectiveSearchPlaceholder = searchPlaceholder ?? t("kumiko.combobox.search-placeholder");
|
|
119
129
|
const effectiveEmptyText = emptyText ?? t("kumiko.combobox.empty");
|
|
120
130
|
const loadingText = t("kumiko.combobox.loading");
|
|
131
|
+
const effectiveCreateLabel = createLabel ?? t("kumiko.actions.create");
|
|
121
132
|
const multiple = props.multiple === true;
|
|
122
133
|
const [open, setOpen] = useState(defaultOpen === true);
|
|
123
134
|
// Local Search-Buffer für Remote-Mode. Tipps werden mit 300ms
|
|
@@ -253,6 +264,20 @@ export function ComboboxInput(props: ComboboxInputProps): ReactNode {
|
|
|
253
264
|
);
|
|
254
265
|
})}
|
|
255
266
|
</Command.List>
|
|
267
|
+
{onCreate !== undefined && (
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
data-testid={`combobox-${id}-create`}
|
|
271
|
+
onClick={() => {
|
|
272
|
+
setOpen(false);
|
|
273
|
+
onCreate();
|
|
274
|
+
}}
|
|
275
|
+
className="flex w-full cursor-pointer select-none items-center gap-2 border-t border-border px-3 py-1.5 text-left text-sm text-primary outline-none hover:bg-accent hover:text-accent-foreground"
|
|
276
|
+
>
|
|
277
|
+
<Plus className="h-4 w-4" />
|
|
278
|
+
<span>{effectiveCreateLabel}</span>
|
|
279
|
+
</button>
|
|
280
|
+
)}
|
|
256
281
|
</Command>
|
|
257
282
|
</PopoverPrimitive.Content>
|
|
258
283
|
</PopoverPrimitive.Portal>
|
package/src/primitives/index.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type DataTableProps,
|
|
26
26
|
type FieldProps,
|
|
27
27
|
type FormProps,
|
|
28
|
+
type FormWidth,
|
|
28
29
|
type GridCellProps,
|
|
29
30
|
type GridProps,
|
|
30
31
|
type HeadingProps,
|
|
@@ -41,11 +42,24 @@ import {
|
|
|
41
42
|
ArrowDown,
|
|
42
43
|
ArrowUp,
|
|
43
44
|
ArrowUpDown,
|
|
45
|
+
Building,
|
|
46
|
+
CalendarDays,
|
|
44
47
|
ChevronDown,
|
|
45
48
|
ChevronLeft,
|
|
46
49
|
ChevronRight,
|
|
50
|
+
Globe,
|
|
51
|
+
Hash,
|
|
52
|
+
KeyRound,
|
|
53
|
+
Link,
|
|
47
54
|
Loader2,
|
|
55
|
+
Lock,
|
|
56
|
+
Mail,
|
|
57
|
+
MapPin,
|
|
48
58
|
MoreHorizontal,
|
|
59
|
+
Phone,
|
|
60
|
+
Search,
|
|
61
|
+
Tag,
|
|
62
|
+
User,
|
|
49
63
|
X,
|
|
50
64
|
} from "lucide-react";
|
|
51
65
|
import {
|
|
@@ -79,6 +93,7 @@ import {
|
|
|
79
93
|
import { FileUploadInput } from "./file-upload";
|
|
80
94
|
import { DefaultLightbox } from "./lightbox";
|
|
81
95
|
import { LocatedTimestampInput } from "./located-timestamp-input";
|
|
96
|
+
import { DefaultModal } from "./modal";
|
|
82
97
|
import { formatMoney, MoneyInput } from "./money-input";
|
|
83
98
|
import { TimestampInput } from "./timestamp-input";
|
|
84
99
|
import { useToast } from "./toast";
|
|
@@ -251,6 +266,46 @@ function DefaultField({
|
|
|
251
266
|
|
|
252
267
|
// ---- Input ----
|
|
253
268
|
|
|
269
|
+
// Field-icon registry: `EditFieldSpec.icon`/`InputProps.icon` sets a
|
|
270
|
+
// symbolic key, mapped here to a lucide component. Unknown keys → no
|
|
271
|
+
// icon (clean fallback, no boot-fail). Mirrors NAV_ICONS' pattern
|
|
272
|
+
// (nav-tree.tsx) — a separate, smaller registry instead of a shared
|
|
273
|
+
// import, because field icons cover a different use case (email, phone,
|
|
274
|
+
// location, …) than nav icons (dashboard, tables, …).
|
|
275
|
+
const FIELD_ICONS: Readonly<Record<string, typeof Mail>> = {
|
|
276
|
+
mail: Mail,
|
|
277
|
+
lock: Lock,
|
|
278
|
+
hash: Hash,
|
|
279
|
+
search: Search,
|
|
280
|
+
user: User,
|
|
281
|
+
phone: Phone,
|
|
282
|
+
calendar: CalendarDays,
|
|
283
|
+
link: Link,
|
|
284
|
+
tag: Tag,
|
|
285
|
+
building: Building,
|
|
286
|
+
globe: Globe,
|
|
287
|
+
key: KeyRound,
|
|
288
|
+
"map-pin": MapPin,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// Wraps a text/number input with a left-positioned prefix icon when
|
|
292
|
+
// `icon` carries a known FIELD_ICONS key. `pl-8` overrides (via
|
|
293
|
+
// tailwind-merge) only the left padding of the vendored ui/input.tsx —
|
|
294
|
+
// right padding and other defaults stay untouched.
|
|
295
|
+
function withFieldIcon(icon: string | undefined, input: ReactNode): ReactNode {
|
|
296
|
+
const Icon = icon !== undefined ? FIELD_ICONS[icon] : undefined;
|
|
297
|
+
if (Icon === undefined) return input;
|
|
298
|
+
return (
|
|
299
|
+
<div className="relative">
|
|
300
|
+
<Icon
|
|
301
|
+
aria-hidden="true"
|
|
302
|
+
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
|
303
|
+
/>
|
|
304
|
+
{input}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
254
309
|
function DefaultInput(props: InputProps): ReactNode {
|
|
255
310
|
// Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
|
|
256
311
|
// selbst — kein manuelles border-destructive mehr nötig.
|
|
@@ -263,7 +318,8 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
263
318
|
} as const;
|
|
264
319
|
switch (props.kind) {
|
|
265
320
|
case "text":
|
|
266
|
-
return (
|
|
321
|
+
return withFieldIcon(
|
|
322
|
+
props.icon,
|
|
267
323
|
<UiInput
|
|
268
324
|
type="text"
|
|
269
325
|
{...common}
|
|
@@ -273,7 +329,8 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
273
329
|
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
274
330
|
{...(props.placeholder !== undefined && { placeholder: props.placeholder })}
|
|
275
331
|
{...(props.autoComplete !== undefined && { autoComplete: props.autoComplete })}
|
|
276
|
-
|
|
332
|
+
className={cn(props.icon !== undefined && FIELD_ICONS[props.icon] ? "pl-8" : undefined)}
|
|
333
|
+
/>,
|
|
277
334
|
);
|
|
278
335
|
case "email":
|
|
279
336
|
return (
|
|
@@ -299,7 +356,8 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
299
356
|
/>
|
|
300
357
|
);
|
|
301
358
|
case "number":
|
|
302
|
-
return (
|
|
359
|
+
return withFieldIcon(
|
|
360
|
+
props.icon,
|
|
303
361
|
<UiInput
|
|
304
362
|
type="number"
|
|
305
363
|
{...common}
|
|
@@ -309,8 +367,11 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
309
367
|
const v = e.target.value;
|
|
310
368
|
props.onChange(v === "" ? undefined : Number(v));
|
|
311
369
|
}}
|
|
312
|
-
className=
|
|
313
|
-
|
|
370
|
+
className={cn(
|
|
371
|
+
"text-right tabular-nums",
|
|
372
|
+
props.icon !== undefined && FIELD_ICONS[props.icon] ? "pl-8" : undefined,
|
|
373
|
+
)}
|
|
374
|
+
/>,
|
|
314
375
|
);
|
|
315
376
|
case "range":
|
|
316
377
|
return (
|
|
@@ -409,6 +470,8 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
409
470
|
...(props.emptyText !== undefined && { emptyText: props.emptyText }),
|
|
410
471
|
...(props.onSearchChange !== undefined && { onSearchChange: props.onSearchChange }),
|
|
411
472
|
...(props.loading !== undefined && { loading: props.loading }),
|
|
473
|
+
...(props.onCreate !== undefined && { onCreate: props.onCreate }),
|
|
474
|
+
...(props.createLabel !== undefined && { createLabel: props.createLabel }),
|
|
412
475
|
} as const;
|
|
413
476
|
if (props.multiple === true) {
|
|
414
477
|
return (
|
|
@@ -966,6 +1029,7 @@ function InfiniteSentinel({
|
|
|
966
1029
|
readonly hasMore: boolean;
|
|
967
1030
|
readonly testId?: string;
|
|
968
1031
|
}): ReactNode {
|
|
1032
|
+
const t = useTranslation();
|
|
969
1033
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
970
1034
|
|
|
971
1035
|
useEffect(() => {
|
|
@@ -1001,7 +1065,7 @@ function InfiniteSentinel({
|
|
|
1001
1065
|
>
|
|
1002
1066
|
{!hasMore ? (
|
|
1003
1067
|
<span data-testid={testId !== undefined ? `${testId}-end` : undefined}>
|
|
1004
|
-
|
|
1068
|
+
{t("kumiko.list.end-of-list")}
|
|
1005
1069
|
</span>
|
|
1006
1070
|
) : loadingMore ? (
|
|
1007
1071
|
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
|
@@ -1407,6 +1471,7 @@ function DefaultForm({
|
|
|
1407
1471
|
subtitle,
|
|
1408
1472
|
actions,
|
|
1409
1473
|
testId,
|
|
1474
|
+
width,
|
|
1410
1475
|
}: FormProps): ReactNode {
|
|
1411
1476
|
// Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
|
|
1412
1477
|
// der Container trägt Card/Titel selbst, sonst Card-in-Card.
|
|
@@ -1444,7 +1509,7 @@ function DefaultForm({
|
|
|
1444
1509
|
data-testid={testId}
|
|
1445
1510
|
className="flex flex-col w-full"
|
|
1446
1511
|
>
|
|
1447
|
-
<FormScreenShell>
|
|
1512
|
+
<FormScreenShell {...(width !== undefined && { maxWidth: width })}>
|
|
1448
1513
|
<div className={cn(cardSurface(), "overflow-hidden")}>
|
|
1449
1514
|
{(title !== undefined || subtitle !== undefined) && (
|
|
1450
1515
|
<div className="px-6 pb-2 pt-5">
|
|
@@ -1500,7 +1565,7 @@ function DefaultForm({
|
|
|
1500
1565
|
// beliebiger max-w-*-Overrides: sm=schmale Auth-Forms, 3xl=Standard-Detail,
|
|
1501
1566
|
// 4xl=tabellen-nahe Forms, full=volle Breite. Inhalt nutzt Card-Primitives;
|
|
1502
1567
|
// `className` (z.B. "flex flex-col gap-6") für Multi-Card-Stacks.
|
|
1503
|
-
export type FormScreenShellWidth =
|
|
1568
|
+
export type FormScreenShellWidth = FormWidth;
|
|
1504
1569
|
|
|
1505
1570
|
const formScreenShellWidth: Record<FormScreenShellWidth, string> = {
|
|
1506
1571
|
sm: "max-w-sm mx-auto",
|
|
@@ -1804,6 +1869,7 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1804
1869
|
Text: DefaultText,
|
|
1805
1870
|
Heading: DefaultHeading,
|
|
1806
1871
|
Dialog: DefaultDialog,
|
|
1872
|
+
Modal: DefaultModal,
|
|
1807
1873
|
Lightbox: DefaultLightbox,
|
|
1808
1874
|
ConfigSourceBadge: DefaultConfigSourceBadge,
|
|
1809
1875
|
ConfigCascadeView: DefaultConfigCascadeView,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Bare content shell for hosting self-contained widgets (own submit/cancel
|
|
2
|
+
// buttons) in a modal overlay — same Radix chrome as DefaultDialog, no
|
|
3
|
+
// footer buttons of its own.
|
|
4
|
+
|
|
5
|
+
import type { ModalProps } from "@cosmicdrift/kumiko-renderer";
|
|
6
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import { cn } from "../lib/cn";
|
|
10
|
+
import { ModalShell } from "./modal-shell";
|
|
11
|
+
|
|
12
|
+
export function DefaultModal({
|
|
13
|
+
open,
|
|
14
|
+
onOpenChange,
|
|
15
|
+
title,
|
|
16
|
+
children,
|
|
17
|
+
testId,
|
|
18
|
+
}: ModalProps): ReactNode {
|
|
19
|
+
const t = useTranslation();
|
|
20
|
+
return (
|
|
21
|
+
<ModalShell
|
|
22
|
+
open={open}
|
|
23
|
+
onOpenChange={onOpenChange}
|
|
24
|
+
testId={testId}
|
|
25
|
+
closeLabel={t("kumiko.dialog.close")}
|
|
26
|
+
noAriaDescription
|
|
27
|
+
contentClassName={cn("grid w-full max-w-lg gap-4 border bg-card p-6 shadow-lg rounded-lg")}
|
|
28
|
+
>
|
|
29
|
+
<DialogPrimitive.Title className="sr-only">{title}</DialogPrimitive.Title>
|
|
30
|
+
{/* React re-parents portal content into the enclosing React tree for
|
|
31
|
+
event bubbling (it only escapes the DOM tree, not the fiber tree) —
|
|
32
|
+
without stopping it here, submitting a form hosted in this modal
|
|
33
|
+
would also bubble into an ancestor <form>'s onSubmit if the modal
|
|
34
|
+
was opened from inside one (e.g. a reference field's create dialog
|
|
35
|
+
nested in the host entity's own form, kumiko-framework#1681). */}
|
|
36
|
+
<div onSubmit={(e) => e.stopPropagation()}>{children}</div>
|
|
37
|
+
</ModalShell>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
|
|
2
|
+
"use client"
|
|
3
|
+
|
|
4
|
+
import { GripVerticalIcon } from "lucide-react"
|
|
5
|
+
import * as ResizablePrimitive from "react-resizable-panels"
|
|
6
|
+
|
|
7
|
+
import { cn } from "../lib/cn"
|
|
8
|
+
|
|
9
|
+
function ResizablePanelGroup({
|
|
10
|
+
className,
|
|
11
|
+
...props
|
|
12
|
+
}: ResizablePrimitive.GroupProps) {
|
|
13
|
+
return (
|
|
14
|
+
<ResizablePrimitive.Group
|
|
15
|
+
data-slot="resizable-panel-group"
|
|
16
|
+
className={cn(
|
|
17
|
+
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
|
18
|
+
className
|
|
19
|
+
)}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
|
26
|
+
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ResizableHandle({
|
|
30
|
+
withHandle,
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
}: ResizablePrimitive.SeparatorProps & {
|
|
34
|
+
withHandle?: boolean
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<ResizablePrimitive.Separator
|
|
38
|
+
data-slot="resizable-handle"
|
|
39
|
+
className={cn(
|
|
40
|
+
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
|
41
|
+
className
|
|
42
|
+
)}
|
|
43
|
+
{...props}
|
|
44
|
+
>
|
|
45
|
+
{withHandle && (
|
|
46
|
+
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-xs border bg-border">
|
|
47
|
+
<GripVerticalIcon className="size-2.5" />
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
50
|
+
</ResizablePrimitive.Separator>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|