@cosmicdrift/kumiko-renderer-web 0.259.0 → 0.260.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.259.0",
3
+ "version": "0.260.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.259.0",
20
- "@cosmicdrift/kumiko-headless": "0.259.0",
21
- "@cosmicdrift/kumiko-renderer": "0.259.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.260.0",
20
+ "@cosmicdrift/kumiko-headless": "0.260.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.260.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",
@@ -66,7 +66,7 @@
66
66
  "@types/react-dom": "^19.2.3",
67
67
  "jsdom": "^29.1.1",
68
68
  "tailwindcss": "^4.3.0",
69
- "@cosmicdrift/kumiko-locale-de": "0.259.0"
69
+ "@cosmicdrift/kumiko-locale-de": "0.260.0"
70
70
  },
71
71
  "repository": {
72
72
  "type": "git",
@@ -42,6 +42,44 @@ function buildSchema(redirect?: string): FeatureSchema {
42
42
  } as FeatureSchema;
43
43
  }
44
44
 
45
+ // The edited "product" stands in for a child record (a deposit movement's
46
+ // deposit, a protocol section's protocol) — a "parent-detail" entityEdit
47
+ // screen is the redirect target, and parentEntity's own field carries the
48
+ // parent id.
49
+ const parentEntity: EntityDefinition = {
50
+ fields: {
51
+ name: { type: "text", required: false, searchable: false, sortable: false },
52
+ parentId: { type: "text", required: false, searchable: false, sortable: false },
53
+ },
54
+ };
55
+
56
+ function buildParentRedirectSchema(): FeatureSchema {
57
+ const editScreen: ScreenDefinition = {
58
+ id: "product-edit",
59
+ type: "entityEdit",
60
+ entity: "product",
61
+ layout: { sections: [{ fields: ["name"] }] },
62
+ redirect: { screen: "parent-detail", idFrom: "parentId" },
63
+ };
64
+ const parentDetailScreen: ScreenDefinition = {
65
+ id: "parent-detail",
66
+ type: "entityEdit",
67
+ entity: "product",
68
+ layout: { sections: [{ fields: ["name"] }] },
69
+ };
70
+ const listScreen: ScreenDefinition = {
71
+ id: "product-list",
72
+ type: "entityList",
73
+ entity: "product",
74
+ columns: ["name"],
75
+ };
76
+ return {
77
+ featureName: "shop",
78
+ entities: { product: parentEntity },
79
+ screens: [editScreen, parentDetailScreen, listScreen],
80
+ } as FeatureSchema;
81
+ }
82
+
45
83
  function fillNameAndSubmit(): void {
46
84
  const nameInput = screen.getByTestId("field-name").querySelector("input") as HTMLInputElement;
47
85
  fireEvent.change(nameInput, { target: { value: "Widget" } });
@@ -306,4 +344,124 @@ describe("entityEdit redirect (#1942)", () => {
306
344
 
307
345
  await waitFor(() => expect(navigated).toEqual([{ screenId: "product-list" }]));
308
346
  });
347
+
348
+ // entityEdit's redirect object form + idFrom — same resolution as
349
+ // actionForm's, reused by EntityEditUpdateForm's handleSubmitted.
350
+ test("update: redirect object form with idFrom falls back to the loaded record when the payload lacks the field", async () => {
351
+ const navigated: NavTarget[] = [];
352
+ const dispatcher = createMockDispatcher({
353
+ query: (async () => ({
354
+ isSuccess: true,
355
+ data: { id: "42", version: 1, name: "Existing", parentId: "parent-1" },
356
+ })) as unknown as Dispatcher["query"],
357
+ // The update handler's success payload reports only its own id
358
+ // (event-store-executor-write.ts) — no "parentId" field.
359
+ write: (async () => ({
360
+ isSuccess: true,
361
+ data: { id: "42" },
362
+ })) as unknown as Dispatcher["write"],
363
+ });
364
+
365
+ render(
366
+ <DispatcherProvider dispatcher={dispatcher}>
367
+ <NavProvider
368
+ value={{
369
+ route: { screenId: "shop:screen:product-edit", entityId: "42" },
370
+ navigate: (target) => navigated.push(target),
371
+ replace: () => {},
372
+ hrefFor: () => "",
373
+ searchParams: {},
374
+ setSearchParams: () => {},
375
+ }}
376
+ >
377
+ <KumikoScreen
378
+ schema={buildParentRedirectSchema()}
379
+ qn="shop:screen:product-edit"
380
+ entityId="42"
381
+ />
382
+ </NavProvider>
383
+ </DispatcherProvider>,
384
+ );
385
+
386
+ await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
387
+ fillNameAndSubmit();
388
+
389
+ await waitFor(() =>
390
+ expect(navigated).toEqual([{ screenId: "parent-detail", entityId: "parent-1" }]),
391
+ );
392
+ });
393
+
394
+ test("update: redirect object form with idFrom prefers the payload value over the loaded record", async () => {
395
+ const navigated: NavTarget[] = [];
396
+ const dispatcher = createMockDispatcher({
397
+ query: (async () => ({
398
+ isSuccess: true,
399
+ data: { id: "42", version: 1, name: "Existing", parentId: "record-parent" },
400
+ })) as unknown as Dispatcher["query"],
401
+ write: (async () => ({
402
+ isSuccess: true,
403
+ data: { id: "42", parentId: "payload-parent" },
404
+ })) as unknown as Dispatcher["write"],
405
+ });
406
+
407
+ render(
408
+ <DispatcherProvider dispatcher={dispatcher}>
409
+ <NavProvider
410
+ value={{
411
+ route: { screenId: "shop:screen:product-edit", entityId: "42" },
412
+ navigate: (target) => navigated.push(target),
413
+ replace: () => {},
414
+ hrefFor: () => "",
415
+ searchParams: {},
416
+ setSearchParams: () => {},
417
+ }}
418
+ >
419
+ <KumikoScreen
420
+ schema={buildParentRedirectSchema()}
421
+ qn="shop:screen:product-edit"
422
+ entityId="42"
423
+ />
424
+ </NavProvider>
425
+ </DispatcherProvider>,
426
+ );
427
+
428
+ await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
429
+ fillNameAndSubmit();
430
+
431
+ await waitFor(() =>
432
+ expect(navigated).toEqual([{ screenId: "parent-detail", entityId: "payload-parent" }]),
433
+ );
434
+ });
435
+
436
+ test("create: redirect object form with idFrom reads the id from the write handler's payload", async () => {
437
+ const navigated: NavTarget[] = [];
438
+ const dispatcher = createMockDispatcher({
439
+ write: (async () => ({
440
+ isSuccess: true,
441
+ data: { id: "new-1", parentId: "parent-1" },
442
+ })) as unknown as Dispatcher["write"],
443
+ });
444
+ render(
445
+ <DispatcherProvider dispatcher={dispatcher}>
446
+ <NavProvider
447
+ value={{
448
+ route: { screenId: "shop:screen:product-edit" },
449
+ navigate: (target) => navigated.push(target),
450
+ replace: () => {},
451
+ hrefFor: () => "",
452
+ searchParams: {},
453
+ setSearchParams: () => {},
454
+ }}
455
+ >
456
+ <KumikoScreen schema={buildParentRedirectSchema()} qn="shop:screen:product-edit" />
457
+ </NavProvider>
458
+ </DispatcherProvider>,
459
+ );
460
+
461
+ fillNameAndSubmit();
462
+
463
+ await waitFor(() =>
464
+ expect(navigated).toEqual([{ screenId: "parent-detail", entityId: "parent-1" }]),
465
+ );
466
+ });
309
467
  });
@@ -1911,6 +1911,40 @@ describe("KumikoScreen", () => {
1911
1911
  expect(writeCalls[0]?.payload).toEqual({ title: "New Task", priority: 1 });
1912
1912
  });
1913
1913
 
1914
+ // slots.header/footer on entityEdit are shared through action-form-shim's
1915
+ // synthesizeActionFormScreen — an actionForm mounts them the same way.
1916
+ test("actionForm: slots.header and slots.footer both render (fw#2853)", async () => {
1917
+ const TopBanner = () => <div data-testid="action-form-header-slot">top banner</div>;
1918
+ const BottomAction = () => <button type="button" data-testid="action-form-footer-slot" />;
1919
+
1920
+ const actionScreen: ActionFormScreenDefinition = {
1921
+ id: "quick-add-slots",
1922
+ type: "actionForm",
1923
+ handler: "tasks:write:task:quick-add",
1924
+ fields: { title: { type: "text", required: true } },
1925
+ layout: { sections: [{ title: "Basics", fields: ["title"] }] },
1926
+ slots: {
1927
+ header: { react: { __component: "TopBanner" } },
1928
+ footer: { react: { __component: "BottomAction" } },
1929
+ },
1930
+ };
1931
+
1932
+ render(
1933
+ <DispatcherProvider dispatcher={makeDispatcher()}>
1934
+ <ExtensionSectionsProvider value={{ TopBanner, BottomAction }}>
1935
+ <KumikoScreen
1936
+ schema={{ ...schema, screens: [actionScreen] }}
1937
+ qn="tasks:screen:quick-add-slots"
1938
+ />
1939
+ </ExtensionSectionsProvider>
1940
+ </DispatcherProvider>,
1941
+ );
1942
+
1943
+ expect(screen.getByTestId("action-form-header-slot")).toBeTruthy();
1944
+ expect(screen.getByTestId("action-form-footer-slot")).toBeTruthy();
1945
+ expect(screen.getByTestId("render-edit-submit")).toBeTruthy();
1946
+ });
1947
+
1914
1948
  // fw#2752: actionForm submitStyle reaches the submit button.
1915
1949
  test("actionForm submitStyle='danger': submit button renders destructive variant", async () => {
1916
1950
  const dispatcher = makeDispatcher({
@@ -404,7 +404,7 @@ describe("DataTable", () => {
404
404
  />
405
405
  </LocaleProvider>,
406
406
  );
407
- expect(screen.getByTestId("t-empty").textContent).toBe("Keine Einträge.");
407
+ expect(screen.getByTestId("t-empty").textContent).toBe("Noch keine Einträge.");
408
408
  });
409
409
 
410
410
  test("rows + cells get individual testIds for E2E hooks", () => {
@@ -79,6 +79,31 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
79
79
  expect(screen.getByTestId("kumiko-screen-projection-detail-status").textContent).toBe("active");
80
80
  });
81
81
 
82
+ test("status sits in the title row; the subtitle sits in its own row below", async () => {
83
+ const headerScreen: ProjectionDetailScreenDefinition = {
84
+ ...baseScreen,
85
+ header: { title: "tenantName", subtitle: "address", status: "state" },
86
+ };
87
+ const dispatcher = dispatcherReturning(rowData);
88
+
89
+ render(
90
+ <DispatcherProvider dispatcher={dispatcher}>
91
+ <KumikoScreen
92
+ schema={schemaFor(headerScreen)}
93
+ qn="rentals:screen:rent-detail"
94
+ entityId="rent-1"
95
+ />
96
+ </DispatcherProvider>,
97
+ );
98
+
99
+ await waitFor(() => screen.getByTestId("render-edit-form"));
100
+ const title = screen.getByTestId("kumiko-screen-projection-detail-title");
101
+ const status = screen.getByTestId("kumiko-screen-projection-detail-status");
102
+ const subtitle = screen.getByTestId("kumiko-screen-projection-detail-subtitle");
103
+ expect(status.parentElement).toBe(title.parentElement);
104
+ expect(subtitle.parentElement).not.toBe(title.parentElement);
105
+ });
106
+
82
107
  test("renders the metrics band with fieldLabels-translated labels and query-row values", async () => {
83
108
  const metricsScreen: ProjectionDetailScreenDefinition = {
84
109
  ...baseScreen,
@@ -158,6 +183,92 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
158
183
  expect(setSearchParamsCalls).toContainEqual({ overdueDays: "3" });
159
184
  });
160
185
 
186
+ test("metric.navigate with only tab — clicking stays on the record and just activates the tab", async () => {
187
+ const navigateCalls: unknown[] = [];
188
+ const setSearchParamsCalls: Readonly<Record<string, string | null>>[] = [];
189
+ const navApi: NavApi = {
190
+ route: undefined,
191
+ navigate: (target) => navigateCalls.push(target),
192
+ replace: () => {},
193
+ hrefFor: () => "",
194
+ searchParams: {},
195
+ setSearchParams: (updates) => setSearchParamsCalls.push(updates),
196
+ };
197
+ const metricsScreen: ProjectionDetailScreenDefinition = {
198
+ ...baseScreen,
199
+ metrics: [
200
+ {
201
+ field: "balance",
202
+ label: "rentals.detail.metric.balance",
203
+ navigate: { tab: "history" },
204
+ },
205
+ ],
206
+ };
207
+ const dispatcher = dispatcherReturning(rowData);
208
+ const user = userEvent.setup();
209
+
210
+ render(
211
+ <NavProvider value={navApi}>
212
+ <DispatcherProvider dispatcher={dispatcher}>
213
+ <KumikoScreen
214
+ schema={schemaFor(metricsScreen)}
215
+ qn="rentals:screen:rent-detail"
216
+ entityId="rent-1"
217
+ />
218
+ </DispatcherProvider>
219
+ </NavProvider>,
220
+ );
221
+
222
+ await waitFor(() => screen.getByTestId("render-edit-form"));
223
+ await user.click(screen.getByTestId("kumiko-screen-projection-detail-metric-balance"));
224
+
225
+ expect(navigateCalls).toEqual([]);
226
+ expect(setSearchParamsCalls).toContainEqual({ tab: "history" });
227
+ });
228
+
229
+ test("metric.navigate with screen + tab — navigates to the screen and sets the tab search param", async () => {
230
+ const navigateCalls: unknown[] = [];
231
+ const setSearchParamsCalls: Readonly<Record<string, string | null>>[] = [];
232
+ const navApi: NavApi = {
233
+ route: undefined,
234
+ navigate: (target) => navigateCalls.push(target),
235
+ replace: () => {},
236
+ hrefFor: () => "",
237
+ searchParams: {},
238
+ setSearchParams: (updates) => setSearchParamsCalls.push(updates),
239
+ };
240
+ const metricsScreen: ProjectionDetailScreenDefinition = {
241
+ ...baseScreen,
242
+ metrics: [
243
+ {
244
+ field: "balance",
245
+ label: "rentals.detail.metric.balance",
246
+ navigate: { screen: "tenant-detail", tab: "history" },
247
+ },
248
+ ],
249
+ };
250
+ const dispatcher = dispatcherReturning(rowData);
251
+ const user = userEvent.setup();
252
+
253
+ render(
254
+ <NavProvider value={navApi}>
255
+ <DispatcherProvider dispatcher={dispatcher}>
256
+ <KumikoScreen
257
+ schema={schemaFor(metricsScreen)}
258
+ qn="rentals:screen:rent-detail"
259
+ entityId="rent-1"
260
+ />
261
+ </DispatcherProvider>
262
+ </NavProvider>,
263
+ );
264
+
265
+ await waitFor(() => screen.getByTestId("render-edit-form"));
266
+ await user.click(screen.getByTestId("kumiko-screen-projection-detail-metric-balance"));
267
+
268
+ expect(navigateCalls).toEqual([{ screenId: "tenant-detail" }]);
269
+ expect(setSearchParamsCalls).toContainEqual({ tab: "history" });
270
+ });
271
+
161
272
  test("header.subtitleHref — an absolute http(s) URL renders the subtitle as an external link", async () => {
162
273
  const headerScreen: ProjectionDetailScreenDefinition = {
163
274
  ...baseScreen,
@@ -3537,3 +3537,333 @@ describe("RenderEdit fields filter", () => {
3537
3537
  expect(write).not.toHaveBeenCalled();
3538
3538
  });
3539
3539
  });
3540
+
3541
+ describe("RenderEdit — slots.header", () => {
3542
+ function HeaderExtra({ entityId }: { readonly entityId: string | null }): ReactNode {
3543
+ return (
3544
+ <div data-testid="header-extra" data-entity-id={entityId ?? "(create)"}>
3545
+ header extra
3546
+ </div>
3547
+ );
3548
+ }
3549
+
3550
+ function makeHeaderScreen(): EntityEditScreenDefinition {
3551
+ return {
3552
+ id: "orders:screen:order-edit-header",
3553
+ type: "entityEdit",
3554
+ entity: "order",
3555
+ layout: {
3556
+ sections: [{ title: "Basics", columns: 1, fields: [{ field: "title" }] }],
3557
+ },
3558
+ slots: { header: { react: { __component: "HeaderExtra" } } },
3559
+ };
3560
+ }
3561
+
3562
+ function makeWizardHeaderScreen(): EntityEditScreenDefinition {
3563
+ return {
3564
+ id: "orders:screen:order-wizard-header",
3565
+ type: "entityEdit",
3566
+ entity: "order",
3567
+ layout: {
3568
+ mode: "wizard",
3569
+ sections: [
3570
+ { title: "Basics", columns: 1, fields: [{ field: "title" }] },
3571
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
3572
+ ],
3573
+ },
3574
+ slots: { header: { react: { __component: "HeaderExtra" } } },
3575
+ };
3576
+ }
3577
+
3578
+ test("header slot renders above the section content, not in the actions container", () => {
3579
+ render(
3580
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3581
+ <ExtensionSectionsProvider value={{ HeaderExtra }}>
3582
+ <RenderEdit<TestValues>
3583
+ screen={makeHeaderScreen()}
3584
+ entity={orderEntity}
3585
+ featureName="orders"
3586
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
3587
+ writeCommand="order:update"
3588
+ entityId="order-1"
3589
+ />
3590
+ </ExtensionSectionsProvider>
3591
+ </DispatcherProvider>,
3592
+ );
3593
+
3594
+ const header = screen.getByTestId("header-extra");
3595
+ const field = screen.getByTestId("field-title");
3596
+ expect(Boolean(header.compareDocumentPosition(field) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(
3597
+ true,
3598
+ );
3599
+ expect(header.getAttribute("data-entity-id")).toBe("order-1");
3600
+ expect(
3601
+ screen.getByTestId("render-edit-form-actions").querySelector('[data-testid="header-extra"]'),
3602
+ ).toBeNull();
3603
+ });
3604
+
3605
+ test("header slot stays visible across wizard steps", async () => {
3606
+ render(
3607
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3608
+ <ExtensionSectionsProvider value={{ HeaderExtra }}>
3609
+ <RenderEdit<TestValues>
3610
+ screen={makeWizardHeaderScreen()}
3611
+ entity={orderEntity}
3612
+ featureName="orders"
3613
+ initial={{ title: "Acme", count: 0 }}
3614
+ writeCommand="order:create"
3615
+ />
3616
+ </ExtensionSectionsProvider>
3617
+ </DispatcherProvider>,
3618
+ );
3619
+
3620
+ expect(screen.getByTestId("header-extra")).toBeTruthy();
3621
+
3622
+ const form = screen.getByTestId("render-edit-form");
3623
+ await act(async () => {
3624
+ fireEvent.submit(form);
3625
+ await Promise.resolve();
3626
+ });
3627
+
3628
+ expect(screen.getByTestId("header-extra")).toBeTruthy();
3629
+ });
3630
+
3631
+ test("header slot renders before a caller-supplied headerRegion", () => {
3632
+ render(
3633
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3634
+ <ExtensionSectionsProvider value={{ HeaderExtra }}>
3635
+ <RenderEdit<TestValues>
3636
+ screen={makeHeaderScreen()}
3637
+ entity={orderEntity}
3638
+ featureName="orders"
3639
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
3640
+ writeCommand="order:update"
3641
+ entityId="order-1"
3642
+ headerRegion={<div data-testid="host-header-region">host header</div>}
3643
+ />
3644
+ </ExtensionSectionsProvider>
3645
+ </DispatcherProvider>,
3646
+ );
3647
+
3648
+ const header = screen.getByTestId("header-extra");
3649
+ const hostRegion = screen.getByTestId("host-header-region");
3650
+ expect(
3651
+ Boolean(header.compareDocumentPosition(hostRegion) & Node.DOCUMENT_POSITION_FOLLOWING),
3652
+ ).toBe(true);
3653
+ });
3654
+ });
3655
+
3656
+ describe("RenderEdit — slots.footer", () => {
3657
+ function FooterExtra({
3658
+ entityId,
3659
+ hasUnsavedChanges,
3660
+ wizardStep,
3661
+ }: {
3662
+ readonly entityId: string | null;
3663
+ readonly hasUnsavedChanges?: boolean;
3664
+ readonly wizardStep?: { readonly index: number; readonly isLast: boolean };
3665
+ }): ReactNode {
3666
+ return (
3667
+ <button
3668
+ type="button"
3669
+ data-testid="footer-extra"
3670
+ data-entity-id={entityId ?? "(create)"}
3671
+ data-dirty={String(hasUnsavedChanges === true)}
3672
+ data-step={wizardStep !== undefined ? String(wizardStep.index) : "undefined"}
3673
+ data-last={wizardStep !== undefined ? String(wizardStep.isLast) : "undefined"}
3674
+ >
3675
+ extra
3676
+ </button>
3677
+ );
3678
+ }
3679
+
3680
+ function makeFooterScreen(): EntityEditScreenDefinition {
3681
+ return {
3682
+ id: "orders:screen:order-edit-footer",
3683
+ type: "entityEdit",
3684
+ entity: "order",
3685
+ layout: {
3686
+ sections: [{ title: "Basics", columns: 1, fields: [{ field: "title" }] }],
3687
+ },
3688
+ slots: { footer: { react: { __component: "FooterExtra" } } },
3689
+ };
3690
+ }
3691
+
3692
+ function makeWizardFooterScreen(): EntityEditScreenDefinition {
3693
+ return {
3694
+ id: "orders:screen:order-wizard-footer",
3695
+ type: "entityEdit",
3696
+ entity: "order",
3697
+ layout: {
3698
+ mode: "wizard",
3699
+ sections: [
3700
+ { title: "Basics", columns: 1, fields: [{ field: "title" }] },
3701
+ { title: "Details", columns: 1, fields: [{ field: "count" }] },
3702
+ ],
3703
+ },
3704
+ slots: { footer: { react: { __component: "FooterExtra" } } },
3705
+ };
3706
+ }
3707
+
3708
+ test("renders inside the form actions container, before the submit button", () => {
3709
+ render(
3710
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3711
+ <ExtensionSectionsProvider value={{ FooterExtra }}>
3712
+ <RenderEdit<TestValues>
3713
+ screen={makeFooterScreen()}
3714
+ entity={orderEntity}
3715
+ featureName="orders"
3716
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
3717
+ writeCommand="order:update"
3718
+ entityId="order-1"
3719
+ />
3720
+ </ExtensionSectionsProvider>
3721
+ </DispatcherProvider>,
3722
+ );
3723
+
3724
+ const actions = screen.getByTestId("render-edit-form-actions");
3725
+ const footer = within(actions).getByTestId("footer-extra");
3726
+ const submit = within(actions).getByTestId("render-edit-submit");
3727
+ expect(Boolean(footer.compareDocumentPosition(submit) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(
3728
+ true,
3729
+ );
3730
+ });
3731
+
3732
+ test("hasUnsavedChanges starts false and flips true after a field change", () => {
3733
+ render(
3734
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3735
+ <ExtensionSectionsProvider value={{ FooterExtra }}>
3736
+ <RenderEdit<TestValues>
3737
+ screen={makeFooterScreen()}
3738
+ entity={orderEntity}
3739
+ featureName="orders"
3740
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
3741
+ writeCommand="order:update"
3742
+ entityId="order-1"
3743
+ />
3744
+ </ExtensionSectionsProvider>
3745
+ </DispatcherProvider>,
3746
+ );
3747
+
3748
+ expect(screen.getByTestId("footer-extra").getAttribute("data-dirty")).toBe("false");
3749
+
3750
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
3751
+ fireEvent.change(titleInput, { target: { value: "Acme Corp" } });
3752
+
3753
+ expect(screen.getByTestId("footer-extra").getAttribute("data-dirty")).toBe("true");
3754
+ });
3755
+
3756
+ test("entityId is passed through", () => {
3757
+ render(
3758
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3759
+ <ExtensionSectionsProvider value={{ FooterExtra }}>
3760
+ <RenderEdit<TestValues>
3761
+ screen={makeFooterScreen()}
3762
+ entity={orderEntity}
3763
+ featureName="orders"
3764
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
3765
+ writeCommand="order:update"
3766
+ entityId="order-77"
3767
+ />
3768
+ </ExtensionSectionsProvider>
3769
+ </DispatcherProvider>,
3770
+ );
3771
+
3772
+ expect(screen.getByTestId("footer-extra").getAttribute("data-entity-id")).toBe("order-77");
3773
+ });
3774
+
3775
+ test("wizardStep reflects the current step and last-step flag, and advances on Next", async () => {
3776
+ render(
3777
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3778
+ <ExtensionSectionsProvider value={{ FooterExtra }}>
3779
+ <RenderEdit<TestValues>
3780
+ screen={makeWizardFooterScreen()}
3781
+ entity={orderEntity}
3782
+ featureName="orders"
3783
+ initial={{ title: "Acme", count: 0 }}
3784
+ writeCommand="order:create"
3785
+ />
3786
+ </ExtensionSectionsProvider>
3787
+ </DispatcherProvider>,
3788
+ );
3789
+
3790
+ expect(screen.getByTestId("footer-extra").getAttribute("data-step")).toBe("0");
3791
+ expect(screen.getByTestId("footer-extra").getAttribute("data-last")).toBe("false");
3792
+
3793
+ const form = screen.getByTestId("render-edit-form");
3794
+ await act(async () => {
3795
+ fireEvent.submit(form);
3796
+ await Promise.resolve();
3797
+ });
3798
+
3799
+ expect(screen.getByTestId("footer-extra").getAttribute("data-step")).toBe("1");
3800
+ expect(screen.getByTestId("footer-extra").getAttribute("data-last")).toBe("true");
3801
+ });
3802
+
3803
+ test("wizardStep is undefined on a non-wizard form", () => {
3804
+ render(
3805
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3806
+ <ExtensionSectionsProvider value={{ FooterExtra }}>
3807
+ <RenderEdit<TestValues>
3808
+ screen={makeFooterScreen()}
3809
+ entity={orderEntity}
3810
+ featureName="orders"
3811
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
3812
+ writeCommand="order:update"
3813
+ entityId="order-1"
3814
+ />
3815
+ </ExtensionSectionsProvider>
3816
+ </DispatcherProvider>,
3817
+ );
3818
+
3819
+ expect(screen.getByTestId("footer-extra").getAttribute("data-step")).toBe("undefined");
3820
+ expect(screen.getByTestId("footer-extra").getAttribute("data-last")).toBe("undefined");
3821
+ });
3822
+
3823
+ test("a declared but unregistered footer slot renders nothing and does not crash; submit stays", () => {
3824
+ render(
3825
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3826
+ <ExtensionSectionsProvider value={{}}>
3827
+ <RenderEdit<TestValues>
3828
+ screen={makeFooterScreen()}
3829
+ entity={orderEntity}
3830
+ featureName="orders"
3831
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
3832
+ writeCommand="order:update"
3833
+ entityId="order-1"
3834
+ />
3835
+ </ExtensionSectionsProvider>
3836
+ </DispatcherProvider>,
3837
+ );
3838
+
3839
+ expect(screen.queryByTestId("footer-extra")).toBeNull();
3840
+ expect(screen.getByTestId("render-edit-submit")).toBeTruthy();
3841
+ });
3842
+
3843
+ test("a screen without slots renders only the pre-existing actions (no extra element)", () => {
3844
+ const screenDef: EntityEditScreenDefinition = {
3845
+ id: "orders:screen:order-edit-no-footer",
3846
+ type: "entityEdit",
3847
+ entity: "order",
3848
+ layout: {
3849
+ sections: [{ title: "Basics", columns: 1, fields: [{ field: "title" }] }],
3850
+ },
3851
+ };
3852
+ render(
3853
+ <DispatcherProvider dispatcher={makeDispatcher()}>
3854
+ <RenderEdit<TestValues>
3855
+ screen={screenDef}
3856
+ entity={orderEntity}
3857
+ featureName="orders"
3858
+ initial={{ title: "Acme", count: 0, isUrgent: false }}
3859
+ writeCommand="order:update"
3860
+ entityId="order-1"
3861
+ />
3862
+ </DispatcherProvider>,
3863
+ );
3864
+
3865
+ const actions = screen.getByTestId("render-edit-form-actions");
3866
+ expect(actions.children.length).toBe(1);
3867
+ expect(actions.children[0]?.getAttribute("data-testid")).toBe("render-edit-submit");
3868
+ });
3869
+ });
@@ -0,0 +1,71 @@
1
+ // kumiko-screen-akte-bedienkonzept: Ctrl/Cmd+Enter submits a textarea
2
+ // (InputProps.onSubmitShortcut) instead of inserting a newline. Plain Enter
3
+ // keeps inserting a newline — asserted here via the keydown event's
4
+ // defaultPrevented state, since fireEvent.keyDown does not itself simulate
5
+ // the browser's native newline insertion.
6
+ import { describe, expect, test } from "bun:test";
7
+ import { fireEvent, render } from "@testing-library/react";
8
+ import { defaultPrimitives } from "../index";
9
+
10
+ const { Input } = defaultPrimitives;
11
+ const noop = () => {};
12
+
13
+ function renderTextarea(onSubmitShortcut?: () => void): HTMLTextAreaElement {
14
+ const { container } = render(
15
+ <Input
16
+ kind="textarea"
17
+ id="notes"
18
+ name="notes"
19
+ value=""
20
+ onChange={noop}
21
+ {...(onSubmitShortcut !== undefined && { onSubmitShortcut })}
22
+ />,
23
+ );
24
+ const textarea = container.querySelector("textarea");
25
+ if (textarea === null) throw new Error("no textarea rendered");
26
+ return textarea;
27
+ }
28
+
29
+ describe("textarea onSubmitShortcut (Ctrl/Cmd+Enter)", () => {
30
+ test("Ctrl+Enter calls onSubmitShortcut and prevents the default newline", () => {
31
+ let calls = 0;
32
+ const textarea = renderTextarea(() => {
33
+ calls += 1;
34
+ });
35
+ // fireEvent.keyDown returns the DOM dispatchEvent() result — false once
36
+ // preventDefault() was called on the (cancelable) event.
37
+ const notPrevented = fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true });
38
+ expect(calls).toBe(1);
39
+ expect(notPrevented).toBe(false);
40
+ });
41
+
42
+ test("Cmd+Enter (metaKey) calls onSubmitShortcut and prevents the default newline", () => {
43
+ let calls = 0;
44
+ const textarea = renderTextarea(() => {
45
+ calls += 1;
46
+ });
47
+ const notPrevented = fireEvent.keyDown(textarea, { key: "Enter", metaKey: true });
48
+ expect(calls).toBe(1);
49
+ expect(notPrevented).toBe(false);
50
+ });
51
+
52
+ test("plain Enter does not call onSubmitShortcut and leaves the newline insertion alone", () => {
53
+ let calls = 0;
54
+ const textarea = renderTextarea(() => {
55
+ calls += 1;
56
+ });
57
+ const notPrevented = fireEvent.keyDown(textarea, { key: "Enter" });
58
+ expect(calls).toBe(0);
59
+ expect(notPrevented).toBe(true);
60
+ });
61
+
62
+ test("without onSubmitShortcut, no aria-keyshortcuts is advertised", () => {
63
+ const textarea = renderTextarea();
64
+ expect(textarea.getAttribute("aria-keyshortcuts")).toBeNull();
65
+ });
66
+
67
+ test("with onSubmitShortcut, aria-keyshortcuts advertises both chords", () => {
68
+ const textarea = renderTextarea(() => {});
69
+ expect(textarea.getAttribute("aria-keyshortcuts")).toBe("Control+Enter Meta+Enter");
70
+ });
71
+ });
@@ -814,6 +814,7 @@ function DefaultInput(props: InputProps): ReactNode {
814
814
  );
815
815
  case "textarea": {
816
816
  const rows = normalizedTextareaRows(props.rows);
817
+ const onSubmitShortcut = props.onSubmitShortcut;
817
818
  return (
818
819
  <Textarea
819
820
  {...common}
@@ -824,6 +825,14 @@ function DefaultInput(props: InputProps): ReactNode {
824
825
  className="resize-y"
825
826
  {...(props.placeholder !== undefined && { placeholder: props.placeholder })}
826
827
  {...(rows !== undefined && { style: textareaMinHeight(rows) })}
828
+ {...(onSubmitShortcut !== undefined && {
829
+ "aria-keyshortcuts": "Control+Enter Meta+Enter",
830
+ onKeyDown: (e: KeyboardEvent<HTMLTextAreaElement>) => {
831
+ if (e.key !== "Enter" || !(e.metaKey || e.ctrlKey)) return;
832
+ e.preventDefault();
833
+ onSubmitShortcut();
834
+ },
835
+ })}
827
836
  />
828
837
  );
829
838
  }
@@ -2359,7 +2368,7 @@ function FormFooter({
2359
2368
  {actions !== undefined && (
2360
2369
  <div
2361
2370
  data-testid={testId !== undefined ? `${testId}-actions` : undefined}
2362
- className="flex items-center gap-2 max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto"
2371
+ className="flex flex-wrap items-center gap-2 max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto"
2363
2372
  >
2364
2373
  {actions}
2365
2374
  </div>