@cosmicdrift/kumiko-renderer 0.254.0 → 0.255.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.254.0",
3
+ "version": "0.255.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.254.0",
19
- "@cosmicdrift/kumiko-headless": "0.254.0",
18
+ "@cosmicdrift/kumiko-framework": "0.255.0",
19
+ "@cosmicdrift/kumiko-headless": "0.255.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.254.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.255.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -17,14 +17,17 @@ import type {
17
17
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
18
18
  import { act, fireEvent, render, waitFor } from "@testing-library/react";
19
19
  import type { ComponentType, ReactNode } from "react";
20
+ import { useState } from "react";
20
21
  import { DispatcherProvider } from "../../context/dispatcher-context";
21
22
  import { UserRolesProvider } from "../../context/user-roles-context";
22
23
  import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
23
24
  import { kumikoDefaultTranslations } from "../../i18n-defaults";
24
25
  import {
26
+ type ActionOverflowMenuProps,
25
27
  type BannerProps,
26
28
  type ButtonProps,
27
29
  type CorePrimitives,
30
+ type DialogProps,
28
31
  type FormProps,
29
32
  PrimitivesProvider,
30
33
  } from "../../primitives";
@@ -70,6 +73,55 @@ const TestBanner: ComponentType<BannerProps> = ({ children, testId }) => (
70
73
  <div data-testid={testId}>{children}</div>
71
74
  );
72
75
 
76
+ // Minimal overflow-menu double (A7): a toggle button reveals the items as
77
+ // plain buttons, own testid per item so a test can assert an item is
78
+ // reachable only after the menu opens — no dropdown/portal machinery, this
79
+ // package doesn't test the real (renderer-web) widget.
80
+ const TestActionOverflowMenu: ComponentType<ActionOverflowMenuProps> = ({
81
+ items,
82
+ label,
83
+ testId,
84
+ }) => {
85
+ const [open, setOpen] = useState(false);
86
+ return (
87
+ <div>
88
+ <button
89
+ type="button"
90
+ aria-label={label}
91
+ data-testid={testId ?? "action-overflow-trigger"}
92
+ onClick={() => setOpen((o) => !o)}
93
+ >
94
+ {"⋯"}
95
+ </button>
96
+ {open &&
97
+ items.map((item) => (
98
+ <button
99
+ key={item.id}
100
+ type="button"
101
+ data-testid={`${testId ?? "action-overflow"}-item-${item.id}`}
102
+ disabled={item.disabled}
103
+ onClick={item.onSelect}
104
+ >
105
+ {item.label}
106
+ </button>
107
+ ))}
108
+ </div>
109
+ );
110
+ };
111
+
112
+ // Records the props of the last Dialog rendered *open* (a closed Dialog from
113
+ // an always-mounted RenderEditActionButton never overwrites it) — lets a
114
+ // test assert a confirm was requested and drive its onConfirm, without a
115
+ // real dialog/portal implementation.
116
+ type DialogSpy = ComponentType<DialogProps> & { lastProps: DialogProps | null };
117
+ const TestDialog: DialogSpy = Object.assign(
118
+ (props: DialogProps): ReactNode => {
119
+ if (props.open) TestDialog.lastProps = props;
120
+ return null;
121
+ },
122
+ { lastProps: null as DialogProps | null },
123
+ );
124
+
73
125
  const testPrimitives: CorePrimitives = {
74
126
  Button: TestButton,
75
127
  Banner: TestBanner,
@@ -83,12 +135,13 @@ const testPrimitives: CorePrimitives = {
83
135
  GridCell: passChildren,
84
136
  Text: passChildren,
85
137
  Heading: noop,
86
- Dialog: noop,
138
+ Dialog: TestDialog,
87
139
  Modal: noop,
88
140
  Lightbox: noop,
89
141
  ConfigSourceBadge: noop,
90
142
  ConfigCascadeView: noop,
91
143
  Link: noop,
144
+ ActionOverflowMenu: TestActionOverflowMenu,
92
145
  };
93
146
 
94
147
  function stubDispatcher(
@@ -416,6 +469,197 @@ describe("projectionDetail default edit action (fw#2166)", () => {
416
469
  expect(navigated).toEqual({ screenId: "rent-edit", entityId: "rent-1" });
417
470
  });
418
471
 
472
+ test("two header actions → both render as buttons, no overflow trigger (fw bedienkonzept A7)", async () => {
473
+ const schema: FeatureSchema = {
474
+ featureName: "app",
475
+ entities: {},
476
+ screens: [
477
+ detailScreen({
478
+ actions: [
479
+ { kind: "navigate", id: "edit", label: "actions.edit", screen: "rent-edit" },
480
+ { kind: "navigate", id: "duplicate", label: "actions.duplicate", screen: "rent-edit" },
481
+ ],
482
+ }),
483
+ editScreen("rent"),
484
+ ],
485
+ };
486
+ const { getByTestId, queryByTestId, queryByText } = renderDetail({
487
+ primarySchema: schema,
488
+ features: [schema],
489
+ userRoles: [],
490
+ });
491
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
492
+
493
+ expect(getByTestId("render-edit-action-edit").textContent).toBe("actions.edit");
494
+ expect(getByTestId("render-edit-action-duplicate").textContent).toBe("actions.duplicate");
495
+ expect(queryByTestId("kumiko-screen-projection-detail-actions-overflow")).toBeNull();
496
+ });
497
+
498
+ test("three header actions → edit stays a text button, the rest collapse into the overflow menu (fw bedienkonzept A7)", async () => {
499
+ const schema: FeatureSchema = {
500
+ featureName: "app",
501
+ entities: {},
502
+ screens: [
503
+ detailScreen({
504
+ actions: [
505
+ { kind: "navigate", id: "edit", label: "actions.edit", screen: "rent-edit" },
506
+ { kind: "navigate", id: "duplicate", label: "actions.duplicate", screen: "rent-edit" },
507
+ { kind: "navigate", id: "audit-log", label: "actions.auditLog", screen: "rent-edit" },
508
+ ],
509
+ }),
510
+ editScreen("rent"),
511
+ ],
512
+ };
513
+ const { getByTestId, queryByTestId, queryByText } = renderDetail({
514
+ primarySchema: schema,
515
+ features: [schema],
516
+ userRoles: [],
517
+ });
518
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
519
+
520
+ expect(getByTestId("render-edit-action-edit").textContent).toBe("actions.edit");
521
+ expect(queryByTestId("render-edit-action-duplicate")).toBeNull();
522
+ expect(queryByTestId("render-edit-action-audit-log")).toBeNull();
523
+
524
+ fireEvent.click(getByTestId("kumiko-screen-projection-detail-actions-overflow"));
525
+ expect(
526
+ getByTestId("kumiko-screen-projection-detail-actions-overflow-item-duplicate").textContent,
527
+ ).toBe("actions.duplicate");
528
+ expect(
529
+ getByTestId("kumiko-screen-projection-detail-actions-overflow-item-audit-log").textContent,
530
+ ).toBe("actions.auditLog");
531
+ });
532
+
533
+ test("four header actions incl. edit → edit stays a text button, the other three are only reachable via the overflow menu (fw bedienkonzept A7)", async () => {
534
+ const schema: FeatureSchema = {
535
+ featureName: "app",
536
+ entities: {},
537
+ screens: [
538
+ detailScreen({
539
+ actions: [
540
+ { kind: "navigate", id: "edit", label: "actions.edit", screen: "rent-edit" },
541
+ { kind: "navigate", id: "duplicate", label: "actions.duplicate", screen: "rent-edit" },
542
+ { kind: "navigate", id: "audit-log", label: "actions.auditLog", screen: "rent-edit" },
543
+ { kind: "navigate", id: "archive", label: "actions.archive", screen: "rent-edit" },
544
+ ],
545
+ }),
546
+ editScreen("rent"),
547
+ ],
548
+ };
549
+ const { getByTestId, queryByTestId, queryByText } = renderDetail({
550
+ primarySchema: schema,
551
+ features: [schema],
552
+ userRoles: [],
553
+ });
554
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
555
+
556
+ expect(getByTestId("render-edit-action-edit").textContent).toBe("actions.edit");
557
+ expect(queryByTestId("render-edit-action-duplicate")).toBeNull();
558
+ expect(queryByTestId("render-edit-action-audit-log")).toBeNull();
559
+ expect(queryByTestId("render-edit-action-archive")).toBeNull();
560
+
561
+ fireEvent.click(getByTestId("kumiko-screen-projection-detail-actions-overflow"));
562
+ expect(
563
+ getByTestId("kumiko-screen-projection-detail-actions-overflow-item-duplicate").textContent,
564
+ ).toBe("actions.duplicate");
565
+ expect(
566
+ getByTestId("kumiko-screen-projection-detail-actions-overflow-item-audit-log").textContent,
567
+ ).toBe("actions.auditLog");
568
+ expect(
569
+ getByTestId("kumiko-screen-projection-detail-actions-overflow-item-archive").textContent,
570
+ ).toBe("actions.archive");
571
+ });
572
+
573
+ test('header action without an id: "edit" entry → the style: "primary" action stays the visible button (fw bedienkonzept A7)', async () => {
574
+ const schema: FeatureSchema = {
575
+ featureName: "app",
576
+ entities: {},
577
+ screens: [
578
+ detailScreen({
579
+ actions: [
580
+ { kind: "navigate", id: "duplicate", label: "actions.duplicate", screen: "rent-edit" },
581
+ {
582
+ kind: "navigate",
583
+ id: "approve",
584
+ label: "actions.approve",
585
+ screen: "rent-edit",
586
+ style: "primary",
587
+ },
588
+ { kind: "navigate", id: "audit-log", label: "actions.auditLog", screen: "rent-edit" },
589
+ ],
590
+ }),
591
+ ],
592
+ };
593
+ const { getByTestId, queryByTestId, queryByText } = renderDetail({
594
+ primarySchema: schema,
595
+ features: [schema],
596
+ userRoles: [],
597
+ });
598
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
599
+
600
+ expect(getByTestId("render-edit-action-approve").textContent).toBe("actions.approve");
601
+ expect(queryByTestId("render-edit-action-duplicate")).toBeNull();
602
+ expect(queryByTestId("render-edit-action-audit-log")).toBeNull();
603
+
604
+ fireEvent.click(getByTestId("kumiko-screen-projection-detail-actions-overflow"));
605
+ expect(
606
+ getByTestId("kumiko-screen-projection-detail-actions-overflow-item-duplicate").textContent,
607
+ ).toBe("actions.duplicate");
608
+ expect(
609
+ getByTestId("kumiko-screen-projection-detail-actions-overflow-item-audit-log").textContent,
610
+ ).toBe("actions.auditLog");
611
+ });
612
+
613
+ test("a danger overflow-menu action still asks for confirmation before firing (fw bedienkonzept A7)", async () => {
614
+ const schema: FeatureSchema = {
615
+ featureName: "app",
616
+ entities: {},
617
+ screens: [
618
+ detailScreen({
619
+ actions: [
620
+ { kind: "navigate", id: "edit", label: "actions.edit", screen: "rent-edit" },
621
+ { kind: "navigate", id: "duplicate", label: "actions.duplicate", screen: "rent-edit" },
622
+ {
623
+ kind: "writeHandler",
624
+ id: "archive",
625
+ label: "actions.archive",
626
+ handler: "app:write:archive",
627
+ style: "danger",
628
+ },
629
+ ],
630
+ }),
631
+ editScreen("rent"),
632
+ ],
633
+ };
634
+ // `as DialogProps | null` widens the assignment so tsc doesn't narrow
635
+ // the property to the literal `null` type, which turns the later
636
+ // optional-chained reads below into `never` (compiler quirk).
637
+ TestDialog.lastProps = null as DialogProps | null;
638
+ const { getByTestId, queryByTestId, queryByText } = renderDetail({
639
+ primarySchema: schema,
640
+ features: [schema],
641
+ userRoles: [],
642
+ writeErrorMessage: "archive failed: rent is still active",
643
+ });
644
+ await waitFor(() => expect(queryByText("Loading…")).toBeNull());
645
+
646
+ fireEvent.click(getByTestId("kumiko-screen-projection-detail-actions-overflow"));
647
+ fireEvent.click(getByTestId("kumiko-screen-projection-detail-actions-overflow-item-archive"));
648
+
649
+ // Danger action must not fire on the menu click alone — it needs the
650
+ // confirm dialog first, same as a danger row action.
651
+ expect(queryByTestId("render-edit-action-error")).toBeNull();
652
+ expect(TestDialog.lastProps?.open).toBe(true);
653
+ expect(TestDialog.lastProps?.title).toBe("actions.archive");
654
+
655
+ await act(async () => {
656
+ await TestDialog.lastProps?.onConfirm();
657
+ });
658
+
659
+ const errorBanner = await waitFor(() => getByTestId("render-edit-action-error"));
660
+ expect(errorBanner.textContent).toBe("archive failed: rent is still active");
661
+ });
662
+
419
663
  test("a failed writeHandler action shows its error in the head region, alongside the action button, NOT in Form's own actions/body regions (fw#2713)", async () => {
420
664
  const schema: FeatureSchema = {
421
665
  featureName: "app",
@@ -6,6 +6,8 @@ import type {
6
6
  EntityDefinition,
7
7
  EntityEditScreenDefinition,
8
8
  EntityListScreenDefinition,
9
+ MetricNavigate,
10
+ MetricSpec,
9
11
  ProjectionDetailScreenDefinition,
10
12
  ProjectionListScreenDefinition,
11
13
  RowAction,
@@ -14,7 +16,11 @@ import type {
14
16
  ScreenDefinition,
15
17
  ToolbarAction,
16
18
  } from "@cosmicdrift/kumiko-framework/ui-types";
17
- import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
19
+ import {
20
+ evalFieldCondition,
21
+ isWriteFormEditSection,
22
+ metricField,
23
+ } from "@cosmicdrift/kumiko-framework/ui-types";
18
24
  import type {
19
25
  Command,
20
26
  FormSnapshot,
@@ -23,11 +29,15 @@ import type {
23
29
  SubmitResult,
24
30
  Translate,
25
31
  } from "@cosmicdrift/kumiko-headless";
26
- import { fieldLabelKey, fieldOptionLabelKey } from "@cosmicdrift/kumiko-headless";
32
+ import { fieldLabelKey, fieldOptionLabelKey, isSafeHref } from "@cosmicdrift/kumiko-headless";
27
33
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
28
34
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog";
29
35
  import { RenderEdit, type RenderEditAction } from "../components/render-edit";
30
- import { RenderEditActionButton } from "../components/render-edit-action-button";
36
+ import {
37
+ needsActionConfirm,
38
+ RenderEditActionButton,
39
+ RenderEditActionConfirmDialog,
40
+ } from "../components/render-edit-action-button";
31
41
  import { RenderList, type ToolbarActionButton } from "../components/render-list";
32
42
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
33
43
  import { useUserRoles } from "../context/user-roles-context";
@@ -37,7 +47,6 @@ import { useTranslation } from "../i18n";
37
47
  import {
38
48
  type DataTableFacet,
39
49
  type DataTableRowAction,
40
- shouldRenderActionsIconOnly,
41
50
  statusToneForValue,
42
51
  usePrimitives,
43
52
  } from "../primitives";
@@ -55,7 +64,7 @@ import {
55
64
  type ResolvedFacetSpec,
56
65
  resolveProjectionFacetSpecs,
57
66
  } from "./list-facets";
58
- import { useNav } from "./nav";
67
+ import { type NavApi, useNav } from "./nav";
59
68
  import {
60
69
  synthesizeProjectionDetailEntity,
61
70
  synthesizeProjectionDetailScreen,
@@ -69,7 +78,6 @@ import {
69
78
  isWriteHandlerRowAction,
70
79
  refetchAfterWrite,
71
80
  resolveActionIcon,
72
- rowActionModeFor,
73
81
  runProjectionRowNavigate,
74
82
  stringifyNavParams,
75
83
  } from "./row-actions";
@@ -1614,12 +1622,6 @@ function EntityListBody({
1614
1622
  openDrawer,
1615
1623
  ]);
1616
1624
 
1617
- // Row actions that all resolve an icon render inline and collapse to
1618
- // icon-only (fw#2580) — the adaptive default would bury more than two of
1619
- // them in a kebab menu. A group with an icon-less member stays adaptive so
1620
- // it never degrades into wall-to-wall text buttons.
1621
- const rowActionMode = rowActionModeFor(rowActions);
1622
-
1623
1625
  // ToolbarActions: Schema → Resolved-Form (analog rowActions).
1624
1626
  // navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
1625
1627
  // → dispatcher.write(handler, payload?()). KumikoScreen kennt schon
@@ -1764,7 +1766,6 @@ function EntityListBody({
1764
1766
  screenPadding
1765
1767
  {...(pager !== undefined && { pager })}
1766
1768
  {...(rowActions !== undefined && { rowActions })}
1767
- {...(rowActionMode !== undefined && { rowActionMode })}
1768
1769
  {...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
1769
1770
  {...(useInfinite && {
1770
1771
  onReachEnd: loadMore,
@@ -1930,10 +1931,6 @@ function ProjectionListBody({
1930
1931
  [screen.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch, openDrawer],
1931
1932
  );
1932
1933
 
1933
- // Same icon-only collapse as entityList (fw#2580) — projectionList rows go
1934
- // through the identical RenderList/DataTable path.
1935
- const rowActionMode = rowActionModeFor(rowActions);
1936
-
1937
1934
  const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
1938
1935
  if (screen.toolbarActions === undefined) return undefined;
1939
1936
  const out: ToolbarActionButton[] = [];
@@ -2055,7 +2052,6 @@ function ProjectionListBody({
2055
2052
  screenPadding
2056
2053
  {...(pager !== undefined && { pager })}
2057
2054
  {...(rowActions !== undefined && { rowActions })}
2058
- {...(rowActionMode !== undefined && { rowActionMode })}
2059
2055
  {...(toolbarActions !== undefined && { toolbarActions })}
2060
2056
  {...(translate !== undefined && { translate })}
2061
2057
  {...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
@@ -2083,6 +2079,65 @@ function ProjectionListBody({
2083
2079
  );
2084
2080
  }
2085
2081
 
2082
+ function metricLabelKey(
2083
+ metric: MetricSpec,
2084
+ fieldLabels: Readonly<Record<string, string>> | undefined,
2085
+ ): string | undefined {
2086
+ if (typeof metric !== "string" && metric.label !== undefined) return metric.label;
2087
+ return fieldLabels?.[metricField(metric)];
2088
+ }
2089
+
2090
+ function metricNavigateSpec(metric: MetricSpec): MetricNavigate | undefined {
2091
+ return typeof metric === "string" ? undefined : metric.navigate;
2092
+ }
2093
+
2094
+ // Reuses runProjectionRowNavigate (row-actions.ts) instead of re-deriving
2095
+ // navigate execution — a metric click has the same entity/screen/params
2096
+ // shape as a RowActionNavigate, minus the fields (id, label, style) that
2097
+ // function never reads.
2098
+ function runMetricNavigate(
2099
+ nav: NavApi,
2100
+ navigate: MetricNavigate,
2101
+ record: Readonly<Record<string, unknown>>,
2102
+ ): void {
2103
+ const base = { kind: "navigate" as const, id: "metric-navigate", label: "" };
2104
+ const action: RowActionNavigate | undefined =
2105
+ navigate.entity !== undefined
2106
+ ? {
2107
+ ...base,
2108
+ entity: navigate.entity,
2109
+ ...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
2110
+ ...(navigate.params !== undefined && { params: navigate.params }),
2111
+ }
2112
+ : navigate.screen !== undefined
2113
+ ? {
2114
+ ...base,
2115
+ screen: navigate.screen,
2116
+ ...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
2117
+ ...(navigate.params !== undefined && { params: navigate.params }),
2118
+ }
2119
+ : undefined;
2120
+ if (action === undefined) return;
2121
+ runProjectionRowNavigate(nav, action, { id: "", values: record });
2122
+ }
2123
+
2124
+ // Absolute http(s) check for RecordHeaderSpec.subtitleHref — deliberately
2125
+ // stricter than isSafeHref (which also allows relative paths and mailto:),
2126
+ // since a relative/mailto value here should render as plain text, not a
2127
+ // "_blank" external link.
2128
+ function isAbsoluteHttpUrl(value: string): boolean {
2129
+ return /^https?:\/\//i.test(value) && isSafeHref(value);
2130
+ }
2131
+
2132
+ function resolveSubtitleHref(
2133
+ header: ProjectionDetailScreenDefinition["header"],
2134
+ record: Readonly<Record<string, unknown>>,
2135
+ ): string | undefined {
2136
+ if (header?.subtitleHref === undefined) return undefined;
2137
+ const value = record[header.subtitleHref];
2138
+ return typeof value === "string" && isAbsoluteHttpUrl(value) ? value : undefined;
2139
+ }
2140
+
2086
2141
  // Projection-Detail-Body — read-only single-row inspector über eine explizite
2087
2142
  // Query statt einer Entity (siehe projection-detail-shim.ts für die Schulden-
2088
2143
  // Doku). Fetcht selbst über `screen.query` + `idParam` (analog zu
@@ -2093,6 +2148,108 @@ function ProjectionListBody({
2093
2148
  // natives Form-Submit (Enter-Keypress) würde ohne customSubmit gegen
2094
2149
  // controller.submit() ohne submit-config throwen — der No-Op macht diesen
2095
2150
  // Pfad harmlos statt ihn dem Zufall zu überlassen.
2151
+ // The primary header action always stays a visible text button — `edit` if
2152
+ // declared, else the first `style: "primary"` action, else the first action
2153
+ // at all. One step longer than row actions' `primaryRowAction` (renderer-web
2154
+ // primitives/index.tsx): rows don't declare `style: "primary"`.
2155
+ function primaryHeaderAction(actions: readonly RenderEditAction[]): RenderEditAction | undefined {
2156
+ return (
2157
+ actions.find((a) => a.id === "edit") ?? actions.find((a) => a.style === "primary") ?? actions[0]
2158
+ );
2159
+ }
2160
+
2161
+ // Header actions bar (A7): <=2 actions render as plain buttons; >2 with an
2162
+ // `ActionOverflowMenu` primitive collapse to the primary button plus a menu
2163
+ // for the rest — same rule as RowActionsCell for table rows. Owns confirm
2164
+ // state for menu items: RenderEditActionButton already owns a per-button
2165
+ // confirm dialog for the always-visible primary action, but a menu item has
2166
+ // no button of its own to carry one.
2167
+ function HeaderActionsBar({
2168
+ actions,
2169
+ Button,
2170
+ Dialog,
2171
+ ActionOverflowMenu,
2172
+ onError,
2173
+ }: {
2174
+ readonly actions: readonly RenderEditAction[];
2175
+ readonly Button: ReturnType<typeof usePrimitives>["Button"];
2176
+ readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
2177
+ readonly ActionOverflowMenu: ReturnType<typeof usePrimitives>["ActionOverflowMenu"];
2178
+ readonly onError: (text: string | null) => void;
2179
+ }): ReactNode {
2180
+ const [pendingAction, setPendingAction] = useState<RenderEditAction | null>(null);
2181
+ const trigger = async (action: RenderEditAction): Promise<void> => {
2182
+ onError(null);
2183
+ try {
2184
+ await action.onPress();
2185
+ } catch (e) {
2186
+ onError(e instanceof Error ? e.message : String(e));
2187
+ }
2188
+ };
2189
+ if (actions.length <= 2 || ActionOverflowMenu === undefined) {
2190
+ return (
2191
+ <>
2192
+ {actions.map((action) => (
2193
+ <RenderEditActionButton
2194
+ key={action.id}
2195
+ action={action}
2196
+ Button={Button}
2197
+ Dialog={Dialog}
2198
+ onError={onError}
2199
+ />
2200
+ ))}
2201
+ </>
2202
+ );
2203
+ }
2204
+ const primary = primaryHeaderAction(actions);
2205
+ const rest = actions.filter((a) => a.id !== primary?.id);
2206
+ return (
2207
+ <>
2208
+ {primary !== undefined && (
2209
+ <RenderEditActionButton
2210
+ key={primary.id}
2211
+ action={primary}
2212
+ Button={Button}
2213
+ Dialog={Dialog}
2214
+ onError={onError}
2215
+ />
2216
+ )}
2217
+ <ActionOverflowMenu
2218
+ label="More actions"
2219
+ testId="kumiko-screen-projection-detail-actions-overflow"
2220
+ items={rest.map((action) => ({
2221
+ id: action.id,
2222
+ label: action.label,
2223
+ ...(action.icon !== undefined && { icon: action.icon }),
2224
+ variant: action.style === "danger" ? ("danger" as const) : ("default" as const),
2225
+ onSelect: () => {
2226
+ if (needsActionConfirm(action)) {
2227
+ setPendingAction(action);
2228
+ } else {
2229
+ void trigger(action);
2230
+ }
2231
+ },
2232
+ }))}
2233
+ />
2234
+ {pendingAction !== null && (
2235
+ <RenderEditActionConfirmDialog
2236
+ action={pendingAction}
2237
+ open={true}
2238
+ onOpenChange={(open) => {
2239
+ if (!open) setPendingAction(null);
2240
+ }}
2241
+ onConfirm={async () => {
2242
+ const action = pendingAction;
2243
+ setPendingAction(null);
2244
+ await trigger(action);
2245
+ }}
2246
+ Dialog={Dialog}
2247
+ />
2248
+ )}
2249
+ </>
2250
+ );
2251
+ }
2252
+
2096
2253
  function ProjectionDetailBody({
2097
2254
  schema,
2098
2255
  screen,
@@ -2107,8 +2264,21 @@ function ProjectionDetailBody({
2107
2264
  readonly translate?: Translate;
2108
2265
  readonly entityId?: string;
2109
2266
  }): ReactNode {
2110
- const { Banner, Button, Dialog, Text, Heading, Grid, GridCell, Card, Tabs, StatusBadge, Metric } =
2111
- usePrimitives();
2267
+ const {
2268
+ Banner,
2269
+ Button,
2270
+ Dialog,
2271
+ Text,
2272
+ Heading,
2273
+ Grid,
2274
+ GridCell,
2275
+ Card,
2276
+ Tabs,
2277
+ StatusBadge,
2278
+ Metric,
2279
+ Link,
2280
+ ActionOverflowMenu,
2281
+ } = usePrimitives();
2112
2282
  const t = useTranslation();
2113
2283
  const effectiveTranslate = translate ?? t;
2114
2284
  const nav = useNav();
@@ -2127,6 +2297,7 @@ function ProjectionDetailBody({
2127
2297
  screen.layout.sections.find((section) => section.id === tabParam) ?? screen.layout.sections[0]
2128
2298
  );
2129
2299
  }, [isTabsMode, Tabs, screen.layout.sections, nav.searchParams]);
2300
+ const hasTabs = isTabsMode && Tabs !== undefined && activeSection !== undefined;
2130
2301
  // Tabs is an optional Core-Primitive: without it the screen falls back to
2131
2302
  // the stacked all-sections layout instead of silently truncating to section 1.
2132
2303
  useEffect(() => {
@@ -2395,28 +2566,23 @@ function ProjectionDetailBody({
2395
2566
  }
2396
2567
  const hasHeader = screen.header !== undefined;
2397
2568
  const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
2398
- const hasTabs = isTabsMode && Tabs !== undefined && activeSection !== undefined;
2399
2569
  // ?? [] rather than threading `headerActions !== undefined` through every
2400
- // use below — an empty array is a safe no-op for .map/.length/icon-collapse.
2570
+ // use below — an empty array is a safe no-op for .map/.length.
2401
2571
  const headerActionsList = headerActions ?? [];
2402
2572
  const hasHeaderActions = headerActionsList.length > 0;
2403
- const headerActionsIconOnly = shouldRenderActionsIconOnly(headerActionsList);
2404
2573
  // Grouped into the head Card alongside title/status/metrics (fw#2713):
2405
2574
  // these are actions on the record the head shows, not on whichever tab is
2406
2575
  // open, so they must stay in place across tab switches instead of
2407
2576
  // trailing the active tab's content in the card footer.
2408
2577
  const headerActionsContent = hasHeaderActions && (
2409
2578
  <Grid columns="auto" testId="kumiko-screen-projection-detail-actions">
2410
- {headerActionsList.map((action) => (
2411
- <RenderEditActionButton
2412
- key={action.id}
2413
- action={action}
2414
- iconOnly={headerActionsIconOnly}
2415
- Button={Button}
2416
- Dialog={Dialog}
2417
- onError={setActionError}
2418
- />
2419
- ))}
2579
+ <HeaderActionsBar
2580
+ actions={headerActionsList}
2581
+ Button={Button}
2582
+ Dialog={Dialog}
2583
+ ActionOverflowMenu={ActionOverflowMenu}
2584
+ onError={setActionError}
2585
+ />
2420
2586
  </Grid>
2421
2587
  );
2422
2588
  const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
@@ -2425,6 +2591,7 @@ function ProjectionDetailBody({
2425
2591
  // it, instead of sitting flush against the screen edge (fw record-screen
2426
2592
  // header polish).
2427
2593
  const header = screen.header;
2594
+ const subtitleHref = resolveSubtitleHref(header, record);
2428
2595
  const headerContent = (
2429
2596
  <>
2430
2597
  {hasHeaderCard && (
@@ -2436,11 +2603,20 @@ function ProjectionDetailBody({
2436
2603
  </Heading>
2437
2604
  {(header.subtitle !== undefined || header.status !== undefined) && (
2438
2605
  <Grid columns="auto">
2439
- {header.subtitle !== undefined && (
2440
- <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2441
- {String(record[header.subtitle] ?? "")}
2442
- </Text>
2443
- )}
2606
+ {header.subtitle !== undefined &&
2607
+ (subtitleHref !== undefined ? (
2608
+ <Link
2609
+ href={subtitleHref}
2610
+ target="_blank"
2611
+ testId="kumiko-screen-projection-detail-subtitle"
2612
+ >
2613
+ {String(record[header.subtitle] ?? "")}
2614
+ </Link>
2615
+ ) : (
2616
+ <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2617
+ {String(record[header.subtitle] ?? "")}
2618
+ </Text>
2619
+ ))}
2444
2620
  {header.status !== undefined &&
2445
2621
  (StatusBadge !== undefined ? (
2446
2622
  <StatusBadge
@@ -2463,14 +2639,26 @@ function ProjectionDetailBody({
2463
2639
  testId="kumiko-screen-projection-detail-metrics"
2464
2640
  >
2465
2641
  {screen.metrics?.map((metric) => {
2466
- const labelKey = screen.fieldLabels?.[metric];
2467
- const label = labelKey !== undefined ? effectiveTranslate(labelKey) : metric;
2468
- const value = String(record[metric] ?? "");
2469
- const testId = `kumiko-screen-projection-detail-metric-${metric}`;
2642
+ const field = metricField(metric);
2643
+ const labelKey = metricLabelKey(metric, screen.fieldLabels);
2644
+ const label = labelKey !== undefined ? effectiveTranslate(labelKey) : field;
2645
+ const value = String(record[field] ?? "");
2646
+ const testId = `kumiko-screen-projection-detail-metric-${field}`;
2647
+ const navigate = metricNavigateSpec(metric);
2648
+ const onPress =
2649
+ navigate !== undefined
2650
+ ? () => runMetricNavigate(nav, navigate, record)
2651
+ : undefined;
2470
2652
  return Metric !== undefined ? (
2471
- <Metric key={metric} label={label} value={value} testId={testId} />
2653
+ <Metric
2654
+ key={field}
2655
+ label={label}
2656
+ value={value}
2657
+ testId={testId}
2658
+ {...(onPress !== undefined && { onPress })}
2659
+ />
2472
2660
  ) : (
2473
- <GridCell key={metric}>
2661
+ <GridCell key={field}>
2474
2662
  <Text variant="small" testId={`${testId}-label`}>
2475
2663
  {label}
2476
2664
  </Text>
@@ -2491,10 +2679,19 @@ function ProjectionDetailBody({
2491
2679
  {hasTabs && activeSection !== undefined && (
2492
2680
  <Tabs
2493
2681
  testId="kumiko-screen-projection-detail-tabs"
2494
- items={screen.layout.sections.map((section) => ({
2495
- id: section.id ?? "",
2496
- label: effectiveTranslate(section.title ?? section.id ?? ""),
2497
- }))}
2682
+ items={screen.layout.sections.map((section) => {
2683
+ const countField = isWriteFormEditSection(section) ? undefined : section.countField;
2684
+ const countValue = countField !== undefined ? record[countField] : undefined;
2685
+ const count =
2686
+ typeof countValue === "number" && Number.isFinite(countValue)
2687
+ ? countValue
2688
+ : undefined;
2689
+ return {
2690
+ id: section.id ?? "",
2691
+ label: effectiveTranslate(section.title ?? section.id ?? ""),
2692
+ ...(count !== undefined && { count }),
2693
+ };
2694
+ })}
2498
2695
  activeId={activeSection.id ?? ""}
2499
2696
  onSelect={(id) => nav.setSearchParams({ tab: id })}
2500
2697
  />
@@ -8,8 +8,7 @@ import type {
8
8
  } from "@cosmicdrift/kumiko-framework/ui-types";
9
9
  import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
10
10
  import type { Dispatcher, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
11
- import type { DataTableRowAction, DataTableRowActionMode } from "../primitives";
12
- import { shouldRenderActionsIconOnly } from "../primitives";
11
+ import type { DataTableRowAction } from "../primitives";
13
12
  import type { NavApi } from "./nav";
14
13
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
15
14
 
@@ -82,18 +81,6 @@ export function resolveActionIcon(id: string, declared?: IconKey): IconKey | und
82
81
  );
83
82
  }
84
83
 
85
- // Row-action column mode for a resolved action set: a group where every
86
- // member carries an icon renders inline so `shouldRenderActionsIconOnly`
87
- // can collapse it to icon-only buttons (fw#2580). Anything else keeps the
88
- // DataTable's adaptive default (kebab past two actions) — inline text
89
- // buttons for an icon-less group are the very thing the collapse avoids.
90
- export function rowActionModeFor(
91
- actions: readonly DataTableRowAction[] | undefined,
92
- ): DataTableRowActionMode | undefined {
93
- if (actions === undefined || !shouldRenderActionsIconOnly(actions)) return undefined;
94
- return "inline";
95
- }
96
-
97
84
  export function stringifyNavParams(params: Record<string, unknown>): Record<string, string | null> {
98
85
  const out: Record<string, string | null> = {};
99
86
  for (const [k, v] of Object.entries(params)) {
@@ -17,11 +17,7 @@ import {
17
17
  resolveProjectionFacetSpecs,
18
18
  } from "../app/list-facets";
19
19
  import { useNav } from "../app/nav";
20
- import {
21
- buildProjectionRowActions,
22
- rowActionModeFor,
23
- runProjectionRowNavigate,
24
- } from "../app/row-actions";
20
+ import { buildProjectionRowActions, runProjectionRowNavigate } from "../app/row-actions";
25
21
  import { dispatcherErrorText } from "../app/write-failed-error";
26
22
  import { useOptionalDispatcher } from "../context/dispatcher-context";
27
23
  import type { ListSort } from "../hooks/use-list-url-state";
@@ -202,7 +198,6 @@ export function RelatedListSection({
202
198
  }),
203
199
  [section.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch, onOpenDrawer],
204
200
  );
205
- const rowActionMode = rowActionModeFor(rowActions);
206
201
 
207
202
  // A truncated fetch means `sortedRows` is a sort of a partial set, not of
208
203
  // the full related-row set — the client-side sort above (or even plain
@@ -251,7 +246,6 @@ export function RelatedListSection({
251
246
  })}
252
247
  {...(onRowClick !== undefined && { onRowClick })}
253
248
  {...(rowActions !== undefined && { rowActions })}
254
- {...(rowActionMode !== undefined && { rowActionMode })}
255
249
  {...(hideTitle === true && { chromeless: true, scrollBody: true })}
256
250
  />
257
251
  </>
@@ -3,6 +3,41 @@ import { useState } from "react";
3
3
  import type { usePrimitives } from "../primitives";
4
4
  import type { RenderEditAction } from "./render-edit-types";
5
5
 
6
+ // Same rule as RowActionWriteHandler: "danger" forces a confirm even
7
+ // without an explicit confirm key — unless `confirmRequired` overrides it
8
+ // (schema-driven navigate/drawer actions, where the target form is itself
9
+ // the confirmation).
10
+ export function needsActionConfirm(action: RenderEditAction): boolean {
11
+ return action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
12
+ }
13
+
14
+ export function RenderEditActionConfirmDialog({
15
+ action,
16
+ open,
17
+ onOpenChange,
18
+ onConfirm,
19
+ Dialog,
20
+ }: {
21
+ readonly action: RenderEditAction;
22
+ readonly open: boolean;
23
+ readonly onOpenChange: (open: boolean) => void;
24
+ readonly onConfirm: () => void | Promise<void>;
25
+ readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
26
+ }): ReactNode {
27
+ return (
28
+ <Dialog
29
+ open={open}
30
+ onOpenChange={onOpenChange}
31
+ title={action.label}
32
+ {...(action.confirm !== undefined && { description: action.confirm })}
33
+ confirmLabel={action.confirmLabel ?? action.label}
34
+ {...(action.style === "danger" && { variant: "danger" as const })}
35
+ onConfirm={onConfirm}
36
+ testId={`render-edit-action-${action.id}-dialog`}
37
+ />
38
+ );
39
+ }
40
+
6
41
  // One header action + its own busy/confirm state — same pattern as
7
42
  // render-list.tsx's ToolbarActionView (each RenderEditAction is
8
43
  // independently bound by the caller, there is no shared trigger pipeline
@@ -38,12 +73,6 @@ export function RenderEditActionButton({
38
73
  };
39
74
 
40
75
  const variant = action.style ?? "secondary";
41
- // Same rule as RowActionWriteHandler: "danger" forces a confirm even
42
- // without an explicit confirm key — unless `confirmRequired` overrides it
43
- // (schema-driven navigate/drawer actions, where the target form is itself
44
- // the confirmation).
45
- const needsConfirm =
46
- action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
47
76
  const showIconOnly = iconOnly && action.icon !== undefined;
48
77
 
49
78
  return (
@@ -55,7 +84,7 @@ export function RenderEditActionButton({
55
84
  {...(action.icon !== undefined && { icon: action.icon })}
56
85
  {...(showIconOnly && { size: "icon" as const, ariaLabel: action.label })}
57
86
  onClick={() => {
58
- if (needsConfirm) {
87
+ if (needsActionConfirm(action)) {
59
88
  setConfirmOpen(true);
60
89
  } else {
61
90
  void trigger();
@@ -65,15 +94,12 @@ export function RenderEditActionButton({
65
94
  >
66
95
  {showIconOnly ? null : action.label}
67
96
  </Button>
68
- <Dialog
97
+ <RenderEditActionConfirmDialog
98
+ action={action}
69
99
  open={confirmOpen}
70
100
  onOpenChange={setConfirmOpen}
71
- title={action.label}
72
- {...(action.confirm !== undefined && { description: action.confirm })}
73
- confirmLabel={action.confirmLabel ?? action.label}
74
- {...(action.style === "danger" && { variant: "danger" as const })}
75
101
  onConfirm={trigger}
76
- testId={`render-edit-action-${action.id}-dialog`}
102
+ Dialog={Dialog}
77
103
  />
78
104
  </>
79
105
  );
@@ -1088,6 +1088,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1088
1088
  {...(screen.layout.width !== undefined && { width: screen.layout.width })}
1089
1089
  {...(headerRegion !== undefined && { headerRegion })}
1090
1090
  {...(fillHeight && { fillHeight })}
1091
+ {...(hideSectionTitles === true && { chromeless: true })}
1091
1092
  >
1092
1093
  {draftCandidates !== null && (
1093
1094
  <Banner
package/src/index.ts CHANGED
@@ -164,6 +164,8 @@ export {
164
164
  } from "./i18n";
165
165
  export { kumikoDefaultTranslations } from "./i18n-defaults";
166
166
  export type {
167
+ ActionMenuItemSpec,
168
+ ActionOverflowMenuProps,
167
169
  AppPrimitives,
168
170
  BannerProps,
169
171
  ButtonProps,
@@ -785,6 +785,15 @@ export type FormProps = {
785
785
  * document-flow height. Native impls may ignore this prop (already a
786
786
  * bounded viewport there). */
787
787
  readonly fillHeight?: boolean;
788
+ /** Drops the surrounding card (bg/border/rounded, title/subtitle block,
789
+ * footer border) so sections+footer render as siblings of `headerRegion`
790
+ * on the page background instead of inside a nested card — a tabbed
791
+ * projectionDetail's tab content is a sibling of the head card, not its
792
+ * child (bedienkonzept A1), so it must look like a standalone screen
793
+ * instead of a form embedded in another card. Default false: unchanged
794
+ * card chrome. Native impls may ignore this prop (no card chrome there
795
+ * to begin with). */
796
+ readonly chromeless?: boolean;
788
797
  };
789
798
 
790
799
  /** Titled Gruppe von Feldern. Web: `<fieldset>` + `<legend>`, Native:
@@ -1054,7 +1063,13 @@ export type WizardStepGroupProps = {
1054
1063
  * Keyboard/ARIA (role=tablist/tab, arrow-key navigation) is the
1055
1064
  * implementation's job. */
1056
1065
  export type TabsProps = {
1057
- readonly items: readonly { readonly id: string; readonly label: string }[];
1066
+ readonly items: readonly {
1067
+ readonly id: string;
1068
+ readonly label: string;
1069
+ /** Shown dimmed after the label (e.g. an open-items count). Omit for
1070
+ * tabs without a counter. */
1071
+ readonly count?: number;
1072
+ }[];
1058
1073
  readonly activeId: string;
1059
1074
  readonly onSelect: (id: string) => void;
1060
1075
  readonly testId?: string;
@@ -1115,6 +1130,9 @@ export type MetricProps = {
1115
1130
  readonly label: string;
1116
1131
  readonly value: string;
1117
1132
  readonly testId?: string;
1133
+ /** Click handler — set when the metric declares a `navigate` target
1134
+ * (see `MetricSpec`). Omitted for a plain, non-interactive metric. */
1135
+ readonly onPress?: () => void;
1118
1136
  };
1119
1137
 
1120
1138
  /** Structured JSON display (audit payload/metadata, job logs, unsupported
@@ -1128,6 +1146,25 @@ export type JsonViewProps = {
1128
1146
  readonly testId?: string;
1129
1147
  };
1130
1148
 
1149
+ /** One item in an `ActionOverflowMenu` (A7: header/row actions beyond the
1150
+ * primary one collapse into this menu instead of a wall of buttons). */
1151
+ export type ActionMenuItemSpec = {
1152
+ readonly id: string;
1153
+ readonly label: string;
1154
+ /** Icon name, resolved by the implementation (same names as row actions). */
1155
+ readonly icon?: IconKey;
1156
+ readonly disabled?: boolean;
1157
+ readonly variant?: "default" | "danger";
1158
+ readonly onSelect: () => void;
1159
+ };
1160
+
1161
+ export type ActionOverflowMenuProps = {
1162
+ readonly items: readonly ActionMenuItemSpec[];
1163
+ /** aria-label for the three-dots trigger. */
1164
+ readonly label: string;
1165
+ readonly testId?: string;
1166
+ };
1167
+
1131
1168
  // ---- Core-Registry (Kumiko-eigene Primitives) ----
1132
1169
 
1133
1170
  export type CorePrimitives = {
@@ -1189,6 +1226,9 @@ export type CorePrimitives = {
1189
1226
  * CorePrimitives mocks in tests keep compiling — additive rollout of
1190
1227
  * a new primitive shouldn't force every test double to grow a stub. */
1191
1228
  readonly FillContainer?: ComponentType<FillContainerProps>;
1229
+ /** Optional: without an implementation, callers with >2 header/row
1230
+ * actions fall back to today's all-buttons-inline rendering (A7). */
1231
+ readonly ActionOverflowMenu?: ComponentType<ActionOverflowMenuProps>;
1192
1232
  };
1193
1233
 
1194
1234
  /** Offene Extension-Zone für App-eigene Primitives. Devs erweitern