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