@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.170.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.170.0",
20
- "@cosmicdrift/kumiko-headless": "0.170.0",
21
- "@cosmicdrift/kumiko-renderer": "0.170.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, "End of list" wenn !hasMore. IntersectionObserver
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(
@@ -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
- <DispatcherProvider dispatcher={dispatcher}>
332
- <LiveEventsProvider value={liveEvents}>
333
- <DashboardBodyProvider value={WebDashboardBody}>
334
- <CustomScreensProvider value={customScreens}>
335
- <ColumnRenderersProvider value={columnRenderers}>
336
- <ExtensionSectionsProvider value={extensionSectionComponents}>
337
- <NavProvidersProvider value={navProviders} entities={navEntities}>
338
- <ResolversProvider resolvers={resolvers}>
339
- <ToastProvider>
340
- <UpdateChecker />
341
- {stackWrappers(providers, stackWrappers(gates, screenNode))}
342
- </ToastProvider>
343
- </ResolversProvider>
344
- </NavProvidersProvider>
345
- </ExtensionSectionsProvider>
346
- </ColumnRenderersProvider>
347
- </CustomScreensProvider>
348
- </DashboardBodyProvider>
349
- </LiveEventsProvider>
350
- </DispatcherProvider>
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,
@@ -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>
@@ -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="text-right tabular-nums"
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
- — End of list
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 = "sm" | "3xl" | "4xl" | "full";
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 }