@cosmicdrift/kumiko-renderer-web 0.231.0 → 0.233.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.231.0",
3
+ "version": "0.233.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.231.0",
20
- "@cosmicdrift/kumiko-headless": "0.231.0",
21
- "@cosmicdrift/kumiko-renderer": "0.231.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.233.0",
20
+ "@cosmicdrift/kumiko-headless": "0.233.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.233.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.231.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.233.0"
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
@@ -15,21 +15,70 @@ import { createMockDispatcher, render, screen } from "./test-utils";
15
15
  const { Form, Section, Button } = defaultPrimitives;
16
16
 
17
17
  describe("DefaultForm Action-Footer", () => {
18
- test("Actions sitzen rechtsbündig am Form-Ende (border-t), Titel ist Heading", () => {
18
+ test("Actions sitzen am Form-Ende (border-t, sm:justify-between), Titel ist Heading", () => {
19
19
  render(
20
20
  <Form onSubmit={() => {}} title="Titel" actions={<Button>Save</Button>} testId="f">
21
21
  <div>body</div>
22
22
  </Form>,
23
23
  );
24
24
  const actions = screen.getByTestId("f-actions");
25
- expect(actions.className).toContain("justify-end");
26
- expect(actions.className).toContain("border-t");
25
+ // The border/layout classes live on the outer footer container (which
26
+ // also wraps the optional secondaryActions group), not on the inner
27
+ // `-actions` div itself.
28
+ const footer = actions.parentElement as HTMLElement;
29
+ expect(footer.className).toContain("sm:justify-between");
30
+ expect(footer.className).toContain("border-t");
27
31
  // Footer sits in the form shell (buttons align with the field edge).
28
32
  expect(actions.closest(".max-w-full")).toBeTruthy();
29
33
  // Title is its own heading above — not inside the action bar.
30
34
  expect(actions.textContent).not.toContain("Titel");
31
35
  expect(screen.getByTestId("f-title").textContent).toBe("Titel");
32
36
  });
37
+
38
+ test("secondaryActions + actions: zwei Gruppen mit eigenen testIds, Container flex-col-reverse", () => {
39
+ render(
40
+ <Form
41
+ onSubmit={() => {}}
42
+ actions={<Button>Save</Button>}
43
+ secondaryActions={<Button variant="danger-ghost">Delete</Button>}
44
+ testId="f"
45
+ >
46
+ <div>body</div>
47
+ </Form>,
48
+ );
49
+ const secondary = screen.getByTestId("f-actions-secondary");
50
+ const main = screen.getByTestId("f-actions");
51
+ expect(secondary.textContent).toBe("Delete");
52
+ expect(main.textContent).toBe("Save");
53
+ // Same outer footer container wraps both groups.
54
+ expect(secondary.parentElement).toBe(main.parentElement);
55
+ const footer = main.parentElement as HTMLElement;
56
+ expect(footer.className).toContain("flex-col-reverse");
57
+ expect(footer.className).toContain("sm:justify-between");
58
+ });
59
+
60
+ test("nur actions (keine secondaryActions): keine -actions-secondary-Gruppe", () => {
61
+ render(
62
+ <Form onSubmit={() => {}} actions={<Button>Save</Button>} testId="f">
63
+ <div>body</div>
64
+ </Form>,
65
+ );
66
+ expect(screen.queryByTestId("f-actions-secondary")).toBeNull();
67
+ expect(screen.getByTestId("f-actions").textContent).toBe("Save");
68
+ });
69
+
70
+ test("nur actions (keine secondaryActions): Gruppe bleibt rechtsbündig (sm:ml-auto)", () => {
71
+ render(
72
+ <Form onSubmit={() => {}} actions={<Button>Save</Button>} testId="f">
73
+ <div>body</div>
74
+ </Form>,
75
+ );
76
+ // justify-between on the footer only pushes the lone group to the edge
77
+ // when there's a second flex child to push away from — with a single
78
+ // child it falls back to flex-start, so the actions group needs its own
79
+ // margin to stay right-aligned on desktop.
80
+ expect(screen.getByTestId("f-actions").className).toContain("sm:ml-auto");
81
+ });
33
82
  });
34
83
 
35
84
  describe("DefaultSection ohne Titel", () => {
@@ -43,7 +43,7 @@ describe("Button", () => {
43
43
  expect(onClick).toHaveBeenCalledTimes(1);
44
44
  });
45
45
 
46
- test("loading: rendert Spinner statt Children + ist disabled", () => {
46
+ test("loading: Spinner ersetzt icon-Slot, Children bleiben sichtbar (kein Width-Jump) + ist disabled", () => {
47
47
  const onClick = mock();
48
48
  render(
49
49
  <Button loading onClick={onClick} testId="btn">
@@ -53,10 +53,28 @@ describe("Button", () => {
53
53
  const btn = screen.getByTestId("btn") as HTMLButtonElement;
54
54
  expect(btn.disabled).toBe(true);
55
55
  expect(btn.dataset["loading"]).toBe("true");
56
- // Children verschwinden während loading; Spinner ist ein <svg>.
57
- expect(btn.textContent).not.toContain("Save");
56
+ expect(btn.textContent).toContain("Save");
58
57
  expect(btn.querySelector("svg")).not.toBeNull();
59
58
  });
59
+
60
+ test("icon: rendert ein SVG vor dem Label, kein SVG ohne icon-Prop", () => {
61
+ render(
62
+ <Button icon="trash" testId="btn">
63
+ Delete
64
+ </Button>,
65
+ );
66
+ const btn = screen.getByTestId("btn") as HTMLButtonElement;
67
+ const svg = btn.querySelector("svg");
68
+ expect(svg).not.toBeNull();
69
+ expect(btn.textContent).toBe("Delete");
70
+ // The icon sits before the label text node in DOM order.
71
+ expect(svg?.compareDocumentPosition(btn.lastChild as Node)).toBe(
72
+ Node.DOCUMENT_POSITION_FOLLOWING,
73
+ );
74
+
75
+ render(<Button testId="btn-no-icon">Delete</Button>);
76
+ expect(screen.getByTestId("btn-no-icon").querySelector("svg")).toBeNull();
77
+ });
60
78
  });
61
79
 
62
80
  describe("Banner", () => {
@@ -234,6 +252,40 @@ describe("Input kind mapping", () => {
234
252
  expect(input.className).toContain("text-right");
235
253
  expect(document.querySelector("svg[aria-hidden='true']")).not.toBeNull();
236
254
  });
255
+
256
+ test('kind="number" unit="km": renders a muted, aria-hidden suffix inside the field and pads the input', () => {
257
+ render(<Input id="i" name="i" kind="number" value={58} unit="km" onChange={() => {}} />);
258
+ const input = screen.getByRole("spinbutton");
259
+ expect(input.className).toContain("pr-8");
260
+ const suffix = screen.getByText("km");
261
+ expect(suffix.getAttribute("aria-hidden")).toBe("true");
262
+ expect((input as HTMLInputElement).value).toBe("58");
263
+ });
264
+
265
+ test('kind="number" without unit: no suffix rendered, no right padding', () => {
266
+ render(<Input id="i" name="i" kind="number" value={58} onChange={() => {}} />);
267
+ expect(screen.queryByText("km")).toBeNull();
268
+ expect(screen.getByRole("spinbutton").className).not.toContain("pr-8");
269
+ });
270
+
271
+ test('kind="number" unit="km": typing only changes the numeric value, the unit never enters it', () => {
272
+ const onChange = mock();
273
+ render(<Input id="i" name="i" kind="number" value={58} unit="km" onChange={onChange} />);
274
+ fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "120" } });
275
+ expect(onChange).toHaveBeenCalledWith(120);
276
+ });
277
+
278
+ test('kind="number" icon="hash" unit="km": both decorations render without clobbering each other', () => {
279
+ render(
280
+ <Input id="i" name="i" kind="number" value={58} icon="hash" unit="km" onChange={() => {}} />,
281
+ );
282
+ const input = screen.getByRole("spinbutton");
283
+ expect(input.className).toContain("pl-8");
284
+ expect(input.className).toContain("pr-8");
285
+ expect(document.querySelector("svg[aria-hidden='true']")).not.toBeNull();
286
+ const suffix = screen.getByText("km");
287
+ expect(suffix.getAttribute("aria-hidden")).toBe("true");
288
+ });
237
289
  });
238
290
 
239
291
  describe("DataTable", () => {
@@ -1202,9 +1254,11 @@ describe("Form", () => {
1202
1254
  </Form>,
1203
1255
  );
1204
1256
  const actionsFooter = screen.getByTestId("form-actions");
1205
- expect(actionsFooter.className).toContain("max-sm:fixed");
1206
- expect(actionsFooter.className).toContain("flex-wrap");
1207
- const contentContainer = actionsFooter.previousElementSibling as HTMLElement;
1257
+ // stickyActions classes live on the outer footer container, which wraps
1258
+ // the (optional) secondary group and the main actions group together.
1259
+ const footer = actionsFooter.parentElement as HTMLElement;
1260
+ expect(footer.className).toContain("max-sm:fixed");
1261
+ const contentContainer = footer.previousElementSibling as HTMLElement;
1208
1262
  expect(contentContainer.className).toContain("max-sm:pb-32");
1209
1263
  });
1210
1264
 
@@ -1215,12 +1269,9 @@ describe("Form", () => {
1215
1269
  </Form>,
1216
1270
  );
1217
1271
  const actionsFooter = screen.getByTestId("form-actions");
1218
- expect(actionsFooter.className).not.toContain("max-sm:fixed");
1219
- // Shared cardFooter constant — this asserts the wrap fix (fw#2528) on the
1220
- // plain (non-sticky) footer, which also covers Section/Card since they
1221
- // render the same constant.
1222
- expect(actionsFooter.className).toContain("flex-wrap");
1223
- const contentContainer = actionsFooter.previousElementSibling as HTMLElement;
1272
+ const footer = actionsFooter.parentElement as HTMLElement;
1273
+ expect(footer.className).not.toContain("max-sm:fixed");
1274
+ const contentContainer = footer.previousElementSibling as HTMLElement;
1224
1275
  expect(contentContainer.className).not.toContain("max-sm:pb-32");
1225
1276
  });
1226
1277
  });
@@ -2927,6 +2927,26 @@ describe("RenderEdit locked state (#1896)", () => {
2927
2927
  expect(onDelete).not.toHaveBeenCalled();
2928
2928
  });
2929
2929
 
2930
+ test("Delete sitzt in der secondary-Gruppe, Submit in der Haupt-Gruppe (fw#2568)", () => {
2931
+ render(
2932
+ <DispatcherProvider dispatcher={makeDispatcher()}>
2933
+ <RenderEdit<TestValues>
2934
+ screen={makeScreen()}
2935
+ entity={orderEntity}
2936
+ featureName="orders"
2937
+ initial={{ title: "Acme", count: 1, isUrgent: false }}
2938
+ writeCommand="order:create"
2939
+ onDelete={async () => {}}
2940
+ />
2941
+ </DispatcherProvider>,
2942
+ );
2943
+
2944
+ const deleteButton = screen.getByTestId("render-edit-delete");
2945
+ const submitButton = screen.getByTestId("render-edit-submit");
2946
+ expect(deleteButton.closest('[data-testid$="-actions-secondary"]')).not.toBeNull();
2947
+ expect(submitButton.closest('[data-testid$="-actions-secondary"]')).toBeNull();
2948
+ });
2949
+
2930
2950
  test("disabled prevents picking a draft candidate from adopting it (fw#1909)", async () => {
2931
2951
  const screenDef: EntityEditScreenDefinition = {
2932
2952
  id: "orders:screen:order-wizard-locked-draftpicker",
@@ -0,0 +1,187 @@
1
+ // DataTable — card layout below the 768px breakpoint (offlot#37). Below
2
+ // that width the table scrolled its columns out of reach with no visible
3
+ // affordance (worse: the `md:sticky` actions column scrolled away WITH the
4
+ // last data columns instead of staying reachable). These tests pin the
5
+ // replacement: below the breakpoint, no <table> at all — one card per row,
6
+ // every ViewModel column present as a label/value pair, actions always
7
+ // visible, and a native <select> standing in for the header-click sort
8
+ // affordance that has no header to attach to down here.
9
+ //
10
+ // Viewport is driven the same way embedded-list-input.test.tsx does it —
11
+ // happy-dom's real innerWidth backs useIsNarrowViewport's matchMedia query,
12
+ // so no matchMedia mock is needed.
13
+
14
+ import { describe, expect, mock, test } from "bun:test";
15
+ import userEvent from "@testing-library/user-event";
16
+ import { defaultPrimitives } from "../primitives";
17
+ import { fireEvent, render, screen, within } from "./test-utils";
18
+
19
+ const { DataTable } = defaultPrimitives;
20
+
21
+ function setViewportWidth(width: number): void {
22
+ (
23
+ window as unknown as { happyDOM: { setInnerWidth: (n: number) => void } }
24
+ ).happyDOM.setInnerWidth(width);
25
+ }
26
+
27
+ function withViewportWidth(width: number, run: () => void): void {
28
+ const originalWidth = window.innerWidth;
29
+ setViewportWidth(width);
30
+ try {
31
+ run();
32
+ } finally {
33
+ setViewportWidth(originalWidth);
34
+ }
35
+ }
36
+
37
+ const LONG_BIO =
38
+ "Anna joined the handler team in 2019 and has led onboarding for every partner integration since, focusing on payment reconciliation edge cases and cross-border tax handling.";
39
+
40
+ const COLUMNS = [
41
+ { field: "name", label: "Name", type: "string", sortable: true },
42
+ { field: "email", label: "Email", type: "string", sortable: false },
43
+ { field: "role", label: "Role", type: "string", sortable: true },
44
+ { field: "bio", label: "Bio", type: "string", sortable: false },
45
+ ] as const;
46
+
47
+ const ROWS = [
48
+ {
49
+ id: "u1",
50
+ values: { name: "Anna Beispiel", email: "anna@haendler.de", role: "Admin", bio: LONG_BIO },
51
+ },
52
+ ];
53
+
54
+ describe("DataTable — cards below 768px", () => {
55
+ test("desktop viewport: table renders as before, no cards", () => {
56
+ withViewportWidth(1024, () => {
57
+ render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
58
+ expect(screen.getByTestId("t").tagName).toBe("TABLE");
59
+ expect(screen.queryByTestId("t-cards")).toBeNull();
60
+ });
61
+ });
62
+
63
+ test("narrow viewport: no <table>, one card per row, every column present as label + value", () => {
64
+ withViewportWidth(500, () => {
65
+ render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
66
+ expect(document.querySelector("table")).toBeNull();
67
+ const card = within(screen.getByTestId("t-cards")).getByTestId("row-u1");
68
+ // Title = first column (none highlighted here) — still findable, and
69
+ // not duplicated as a label/value pair below.
70
+ expect(card.textContent).toContain("Anna Beispiel");
71
+ for (const col of COLUMNS.filter((c) => c.field !== "name")) {
72
+ expect(within(card).getByText(col.label)).not.toBeNull();
73
+ }
74
+ expect(within(card).getByTestId("cell-u1-email").textContent).toBe("anna@haendler.de");
75
+ expect(within(card).getByTestId("cell-u1-role").textContent).toBe("Admin");
76
+ expect(within(card).getByTestId("cell-u1-bio").textContent).toBe(LONG_BIO);
77
+ });
78
+ });
79
+
80
+ test("highlighted column becomes the card title and does not repeat as a label/value pair", () => {
81
+ withViewportWidth(500, () => {
82
+ const columns = COLUMNS.map((c) => (c.field === "role" ? { ...c, highlighted: true } : c));
83
+ render(<DataTable columns={columns} rows={ROWS} testId="t" />);
84
+ const card = within(screen.getByTestId("t-cards")).getByTestId("row-u1");
85
+ expect(within(card).queryByText("Role")).toBeNull();
86
+ expect(within(card).getByText("Name")).not.toBeNull();
87
+ });
88
+ });
89
+
90
+ test("a value the table would truncate is shown in full in the card, without the truncate class", () => {
91
+ const originalWidth = window.innerWidth;
92
+ try {
93
+ setViewportWidth(1024);
94
+ const { unmount } = render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
95
+ const desktopCell = screen.getByTestId("cell-u1-bio");
96
+ expect(desktopCell.className).toContain("truncate");
97
+ unmount();
98
+
99
+ setViewportWidth(500);
100
+ render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
101
+ const cardCell = screen.getByTestId("cell-u1-bio");
102
+ expect(cardCell.className).not.toContain("truncate");
103
+ expect(cardCell.textContent).toBe(LONG_BIO);
104
+ } finally {
105
+ setViewportWidth(originalWidth);
106
+ }
107
+ });
108
+
109
+ test("row actions are present in the DOM and operable in card mode", async () => {
110
+ const originalWidth = window.innerWidth;
111
+ setViewportWidth(500);
112
+ try {
113
+ const onTrigger = mock();
114
+ render(
115
+ <DataTable
116
+ columns={COLUMNS}
117
+ rows={ROWS}
118
+ rowActions={[{ id: "edit", label: "Edit", onTrigger }]}
119
+ testId="t"
120
+ />,
121
+ );
122
+ const button = screen.getByTestId("row-u1-action-edit");
123
+ await userEvent.setup().click(button);
124
+ expect(onTrigger).toHaveBeenCalledTimes(1);
125
+ } finally {
126
+ setViewportWidth(originalWidth);
127
+ }
128
+ });
129
+
130
+ test("empty state renders the same way in card mode, no card container", () => {
131
+ withViewportWidth(500, () => {
132
+ render(<DataTable columns={COLUMNS} rows={[]} testId="t" />);
133
+ expect(screen.getByTestId("t-empty")).not.toBeNull();
134
+ expect(screen.queryByTestId("t-cards")).toBeNull();
135
+ });
136
+ });
137
+ });
138
+
139
+ // No column headers below the breakpoint, so SortableHeader's click-to-sort
140
+ // has nothing to attach to — a native <select> fed from the sortable
141
+ // columns is the whole replacement, only rendered when there is something
142
+ // to sort and somewhere for the result to go.
143
+ describe("DataTable — card-mode sort select", () => {
144
+ test("lists only the sortable columns, both directions, and reports the picked one", () => {
145
+ withViewportWidth(500, () => {
146
+ const onSortChange = mock();
147
+ render(<DataTable columns={COLUMNS} rows={ROWS} onSortChange={onSortChange} testId="t" />);
148
+ const select = screen.getByTestId("t-sort") as HTMLSelectElement;
149
+ const optionLabels = Array.from(select.options).map((o) => o.textContent);
150
+ expect(optionLabels).toEqual(["Unsorted", "Name ↑", "Name ↓", "Role ↑", "Role ↓"]);
151
+
152
+ fireEvent.change(select, { target: { value: "role:desc" } });
153
+ expect(onSortChange).toHaveBeenCalledWith({ field: "role", dir: "desc" });
154
+ });
155
+ });
156
+
157
+ test("no select without onSortChange — nothing to wire it to", () => {
158
+ withViewportWidth(500, () => {
159
+ render(<DataTable columns={COLUMNS} rows={ROWS} testId="t" />);
160
+ expect(screen.queryByTestId("t-sort")).toBeNull();
161
+ });
162
+ });
163
+
164
+ test("no select when no column is sortable", () => {
165
+ withViewportWidth(500, () => {
166
+ const onSortChange = mock();
167
+ const nonSortableColumns = COLUMNS.map((c) => ({ ...c, sortable: false }));
168
+ render(
169
+ <DataTable
170
+ columns={nonSortableColumns}
171
+ rows={ROWS}
172
+ onSortChange={onSortChange}
173
+ testId="t"
174
+ />,
175
+ );
176
+ expect(screen.queryByTestId("t-sort")).toBeNull();
177
+ });
178
+ });
179
+
180
+ test("desktop viewport never renders the select — header clicks already cover it", () => {
181
+ withViewportWidth(1024, () => {
182
+ const onSortChange = mock();
183
+ render(<DataTable columns={COLUMNS} rows={ROWS} onSortChange={onSortChange} testId="t" />);
184
+ expect(screen.queryByTestId("t-sort")).toBeNull();
185
+ });
186
+ });
187
+ });
package/src/icons.tsx ADDED
@@ -0,0 +1,116 @@
1
+ import type { NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import {
3
+ ArrowLeft,
4
+ ArrowRight,
5
+ BarChart3,
6
+ Bell,
7
+ BookOpen,
8
+ Building,
9
+ Calculator,
10
+ CalendarDays,
11
+ Check,
12
+ ClipboardList,
13
+ Coins,
14
+ Copy,
15
+ CreditCard,
16
+ Download,
17
+ FileText,
18
+ Folder,
19
+ FolderOpen,
20
+ Gauge,
21
+ Hash,
22
+ Home,
23
+ KeyRound,
24
+ Languages,
25
+ Layers,
26
+ LayoutDashboard,
27
+ LayoutGrid,
28
+ LineChart,
29
+ Link,
30
+ List,
31
+ Lock,
32
+ Mail,
33
+ Package,
34
+ Palette,
35
+ PiggyBank,
36
+ Plus,
37
+ Receipt,
38
+ Rocket,
39
+ Search,
40
+ Send,
41
+ Server,
42
+ Settings,
43
+ Share2,
44
+ Shield,
45
+ ShieldCheck,
46
+ Sparkles,
47
+ Table,
48
+ Tag,
49
+ Trash2,
50
+ TrendingUp,
51
+ Upload,
52
+ User,
53
+ Users,
54
+ Wallet,
55
+ Wand2,
56
+ X,
57
+ } from "lucide-react";
58
+
59
+ // Buttons resolve icons through the same map, importing it from the nav layout module would couple them to the sidebar.
60
+ export const NAV_ICONS = {
61
+ dashboard: LayoutDashboard,
62
+ "layout-grid": LayoutGrid,
63
+ "book-open": BookOpen,
64
+ "clipboard-list": ClipboardList,
65
+ package: Package,
66
+ gauge: Gauge,
67
+ list: List,
68
+ table: Table,
69
+ layers: Layers,
70
+ building: Building,
71
+ calculator: Calculator,
72
+ wallet: Wallet,
73
+ coins: Coins,
74
+ "credit-card": CreditCard,
75
+ "piggy-bank": PiggyBank,
76
+ receipt: Receipt,
77
+ chart: LineChart,
78
+ "bar-chart": BarChart3,
79
+ trending: TrendingUp,
80
+ sparkles: Sparkles,
81
+ wand: Wand2,
82
+ calendar: CalendarDays,
83
+ file: FileText,
84
+ folder: Folder,
85
+ "folder-open": FolderOpen,
86
+ home: Home,
87
+ bell: Bell,
88
+ shield: Shield,
89
+ "shield-check": ShieldCheck,
90
+ send: Send,
91
+ settings: Settings,
92
+ users: Users,
93
+ user: User,
94
+ search: Search,
95
+ tag: Tag,
96
+ key: KeyRound,
97
+ link: Link,
98
+ palette: Palette,
99
+ share: Share2,
100
+ server: Server,
101
+ mail: Mail,
102
+ lock: Lock,
103
+ hash: Hash,
104
+ download: Download,
105
+ upload: Upload,
106
+ rocket: Rocket,
107
+ // Was imported but never registered — `icon: "plus"` silently fell back.
108
+ plus: Plus,
109
+ languages: Languages,
110
+ trash: Trash2,
111
+ x: X,
112
+ check: Check,
113
+ "arrow-left": ArrowLeft,
114
+ "arrow-right": ArrowRight,
115
+ copy: Copy,
116
+ } as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
@@ -17,7 +17,7 @@ import type {
17
17
  TreeAction,
18
18
  TreeNode,
19
19
  } from "@cosmicdrift/kumiko-framework/engine";
20
- import type { NavDefinition, NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
20
+ import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
21
21
  import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
22
22
  import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
23
23
  import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
@@ -28,58 +28,7 @@ import {
28
28
  useNav,
29
29
  useTranslation,
30
30
  } from "@cosmicdrift/kumiko-renderer";
31
- import {
32
- BarChart3,
33
- Bell,
34
- BookOpen,
35
- Building,
36
- Calculator,
37
- CalendarDays,
38
- ChevronDown,
39
- ChevronRight,
40
- ClipboardList,
41
- Coins,
42
- CreditCard,
43
- Download,
44
- FileText,
45
- Folder,
46
- FolderOpen,
47
- Gauge,
48
- Hash,
49
- Home,
50
- KeyRound,
51
- Languages,
52
- Layers,
53
- LayoutDashboard,
54
- LayoutGrid,
55
- LineChart,
56
- Link,
57
- List,
58
- Lock,
59
- Mail,
60
- Package,
61
- Palette,
62
- PiggyBank,
63
- Plus,
64
- Receipt,
65
- Rocket,
66
- Search,
67
- Send,
68
- Server,
69
- Settings,
70
- Share2,
71
- Shield,
72
- ShieldCheck,
73
- Sparkles,
74
- Table,
75
- Tag,
76
- TrendingUp,
77
- Upload,
78
- User,
79
- Users,
80
- Wallet,
81
- Wand2,
82
- } from "lucide-react";
31
+ import { ChevronDown, ChevronRight, type Folder, Plus } from "lucide-react";
83
32
  import {
84
33
  createContext,
85
34
  type ReactNode,
@@ -91,6 +40,7 @@ import {
91
40
  } from "react";
92
41
  import { KumikoLink } from "../app/nav";
93
42
  import { useNavEntities, useNavProviders } from "../app/nav-providers-context";
43
+ import { NAV_ICONS } from "../icons";
94
44
  import { cn } from "../lib/cn";
95
45
  import {
96
46
  SidebarGroup,
@@ -109,69 +59,6 @@ import {
109
59
  import { useDispatchTarget } from "./target-resolver-stub";
110
60
  import { parseTargetFromSearchParams } from "./target-url";
111
61
 
112
- // Nav-icon registry: a nav entry sets `icon: "<key>"` (in the r.nav decl),
113
- // the renderer maps the symbolic key to a lucide component. `NavIconKey` is
114
- // the closed vocabulary a feature author can write (packages/types/src/
115
- // nav-icon.ts); `satisfies` below makes this map a compile-time drift
116
- // guard — a key added to one without the other fails the build.
117
- //
118
- // `node.icon`/`TreeAction.icon` stay plain `string` at the resolved-tree
119
- // layer (dynamic/provider-supplied data isn't statically known), so the
120
- // runtime `Object.hasOwn` lookups below still see an unknown key on
121
- // occasion — that's the defense-in-depth fallback to the dot, not the
122
- // primary guard anymore.
123
- const NAV_ICONS = {
124
- dashboard: LayoutDashboard,
125
- "layout-grid": LayoutGrid,
126
- "book-open": BookOpen,
127
- "clipboard-list": ClipboardList,
128
- package: Package,
129
- gauge: Gauge,
130
- list: List,
131
- table: Table,
132
- layers: Layers,
133
- building: Building,
134
- calculator: Calculator,
135
- wallet: Wallet,
136
- coins: Coins,
137
- "credit-card": CreditCard,
138
- "piggy-bank": PiggyBank,
139
- receipt: Receipt,
140
- chart: LineChart,
141
- "bar-chart": BarChart3,
142
- trending: TrendingUp,
143
- sparkles: Sparkles,
144
- wand: Wand2,
145
- calendar: CalendarDays,
146
- file: FileText,
147
- folder: Folder,
148
- "folder-open": FolderOpen,
149
- home: Home,
150
- bell: Bell,
151
- shield: Shield,
152
- "shield-check": ShieldCheck,
153
- send: Send,
154
- settings: Settings,
155
- users: Users,
156
- user: User,
157
- search: Search,
158
- tag: Tag,
159
- key: KeyRound,
160
- link: Link,
161
- palette: Palette,
162
- share: Share2,
163
- server: Server,
164
- mail: Mail,
165
- lock: Lock,
166
- hash: Hash,
167
- download: Download,
168
- upload: Upload,
169
- rocket: Rocket,
170
- // Was imported but never registered — `icon: "plus"` silently fell back.
171
- plus: Plus,
172
- languages: Languages,
173
- } as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
174
-
175
62
  // Widened alias for the two lookup sites below, which index by the plain
176
63
  // `string` icon key of the resolved NavNode/TreeAction tree — not the
177
64
  // closed NavIconKey union NAV_ICONS itself is typed against.
@@ -711,7 +598,10 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
711
598
  {...(s.active && { "aria-current": "page" })}
712
599
  >
713
600
  <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
714
- <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
601
+ <span
602
+ className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
603
+ title={s.displayLabel}
604
+ >
715
605
  {s.displayLabel}
716
606
  </span>
717
607
  <NavBadge node={node} />
@@ -734,7 +624,10 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
734
624
  onClick={() => dispatch(target)}
735
625
  >
736
626
  <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
737
- <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
627
+ <span
628
+ className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
629
+ title={s.displayLabel}
630
+ >
738
631
  {s.displayLabel}
739
632
  </span>
740
633
  <NavBadge node={node} />
@@ -755,7 +648,9 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
755
648
  {...(s.expandable && { "aria-expanded": s.isExpanded })}
756
649
  >
757
650
  <NavLeadingIcon node={node} active={false} expanded={s.isExpanded} label={s.displayLabel} />
758
- <span className="truncate group-data-[collapsible=icon]:hidden">{s.displayLabel}</span>
651
+ <span className="truncate group-data-[collapsible=icon]:hidden" title={s.displayLabel}>
652
+ {s.displayLabel}
653
+ </span>
759
654
  {s.expandable &&
760
655
  (s.isExpanded ? (
761
656
  <ChevronDown className="ml-auto" />
@@ -823,7 +718,10 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
823
718
  {...(s.active && { "aria-current": "page" })}
824
719
  >
825
720
  <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
826
- <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
721
+ <span
722
+ className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
723
+ title={s.displayLabel}
724
+ >
827
725
  {s.displayLabel}
828
726
  </span>
829
727
  <NavBadge node={node} />
@@ -843,7 +741,10 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
843
741
  <SidebarMenuSubButton asChild isActive={s.active}>
844
742
  <button type="button" onClick={() => dispatch(target)}>
845
743
  <NavLeadingIcon node={node} active={s.active} label={s.displayLabel} />
846
- <span className="min-w-0 truncate group-data-[collapsible=icon]:hidden">
744
+ <span
745
+ className="min-w-0 truncate group-data-[collapsible=icon]:hidden"
746
+ title={s.displayLabel}
747
+ >
847
748
  {s.displayLabel}
848
749
  </span>
849
750
  <NavBadge node={node} />
@@ -871,7 +772,9 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
871
772
  expanded={s.isExpanded}
872
773
  label={s.displayLabel}
873
774
  />
874
- <span className="truncate group-data-[collapsible=icon]:hidden">{s.displayLabel}</span>
775
+ <span className="truncate group-data-[collapsible=icon]:hidden" title={s.displayLabel}>
776
+ {s.displayLabel}
777
+ </span>
875
778
  </button>
876
779
  </SidebarMenuSubButton>
877
780
  <NodeActions node={node} />
@@ -216,6 +216,39 @@ describe("defaultCellRender", () => {
216
216
  expect(defaultCellRender("hallo", "text")).toBe("hallo");
217
217
  });
218
218
 
219
+ test("text-Spalte mit vollem ISO-8601-Zeitstempel warnt einmal pro Spalte, Wert bleibt unverändert (fw#2569)", () => {
220
+ const prevNodeEnv = process.env.NODE_ENV;
221
+ process.env.NODE_ENV = "development";
222
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
223
+ try {
224
+ const iso = "2026-09-04T12:30:00.000Z";
225
+ expect(defaultCellRender(iso, "text", undefined, undefined, "createdAt")).toBe(iso);
226
+ expect(warn).toHaveBeenCalledTimes(1);
227
+ expect(warn.mock.calls[0]?.[0]).toContain("createdAt");
228
+ // Same column, second row with a different ISO value → no second warning.
229
+ expect(
230
+ defaultCellRender("2026-09-05T08:00:00Z", "text", undefined, undefined, "createdAt"),
231
+ ).toBe("2026-09-05T08:00:00Z");
232
+ expect(warn).toHaveBeenCalledTimes(1);
233
+ // A different column still gets its own warning.
234
+ expect(defaultCellRender(iso, "text", undefined, undefined, "expiresAt")).toBe(iso);
235
+ expect(warn).toHaveBeenCalledTimes(2);
236
+ // Plain text and date-only strings never trigger the warning.
237
+ expect(defaultCellRender("hallo", "text", undefined, undefined, "name")).toBe("hallo");
238
+ expect(defaultCellRender("2026-09-04", "text", undefined, undefined, "day")).toBe(
239
+ "2026-09-04",
240
+ );
241
+ expect(warn).toHaveBeenCalledTimes(2);
242
+ } finally {
243
+ warn.mockRestore();
244
+ if (prevNodeEnv === undefined) {
245
+ delete process.env.NODE_ENV;
246
+ } else {
247
+ process.env.NODE_ENV = prevNodeEnv;
248
+ }
249
+ }
250
+ });
251
+
219
252
  test("number/decimal → locale-formatiert, kein roher Dezimalpunkt (fw#2160)", () => {
220
253
  expect(defaultCellRender(42, "number")).toBe(new Intl.NumberFormat(undefined).format(42));
221
254
  expect(defaultCellRender(245.5, "decimal")).toBe(
@@ -73,6 +73,7 @@ import {
73
73
  Children,
74
74
  type CSSProperties,
75
75
  createContext,
76
+ type KeyboardEvent,
76
77
  type MouseEvent,
77
78
  type ReactNode,
78
79
  useContext,
@@ -80,6 +81,7 @@ import {
80
81
  useRef,
81
82
  useState,
82
83
  } from "react";
84
+ import { NAV_ICONS } from "../icons";
83
85
  import { cn } from "../lib/cn";
84
86
  import { Badge } from "../ui/badge";
85
87
  import { buttonVariants, Button as UiButton } from "../ui/button";
@@ -113,6 +115,7 @@ import { DefaultTabs } from "./tabs";
113
115
  import { TimestampInput } from "./timestamp-input";
114
116
  import { useToast } from "./toast";
115
117
  import { TzInput } from "./tz-input";
118
+ import { useIsNarrowViewport } from "./use-narrow-viewport";
116
119
 
117
120
  // ---- Card-Chrome (eine Definition für Form/Section/Card) ----
118
121
 
@@ -141,6 +144,7 @@ const BUTTON_VARIANT = {
141
144
  secondary: "outline",
142
145
  danger: "destructive",
143
146
  link: "link",
147
+ "danger-ghost": "ghost",
144
148
  } as const;
145
149
 
146
150
  const BUTTON_SIZE = {
@@ -162,14 +166,29 @@ function DefaultButton({
162
166
  testId,
163
167
  className,
164
168
  ref,
169
+ icon,
170
+ iconEnd,
165
171
  }: ButtonProps): ReactNode {
166
172
  // link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
167
173
  // gepolsterte Fläche; width="full" streckt CTA-Buttons in Karten/Panels.
168
174
  const resolvedClassName = cn(
169
175
  variant === "link" ? "h-auto px-0 py-0" : "",
176
+ variant === "danger-ghost"
177
+ ? "text-destructive hover:text-destructive hover:bg-destructive/10"
178
+ : "",
170
179
  width === "full" ? "w-full" : "",
171
180
  className,
172
181
  );
182
+ const IconStart = icon !== undefined ? NAV_ICONS[icon] : undefined;
183
+ const IconEnd = iconEnd !== undefined ? NAV_ICONS[iconEnd] : undefined;
184
+ // Loading swaps the leading icon slot for a spinner and keeps `children` —
185
+ // replacing the label would shift the button's width mid-submit.
186
+ const leading =
187
+ loading === true ? (
188
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
189
+ ) : IconStart !== undefined ? (
190
+ <IconStart className="size-4" aria-hidden="true" />
191
+ ) : null;
173
192
  return (
174
193
  <UiButton
175
194
  ref={ref}
@@ -183,7 +202,9 @@ function DefaultButton({
183
202
  aria-label={ariaLabel}
184
203
  className={resolvedClassName}
185
204
  >
186
- {loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
205
+ {leading}
206
+ {children}
207
+ {IconEnd !== undefined && <IconEnd className="size-4" aria-hidden="true" />}
187
208
  </UiButton>
188
209
  );
189
210
  }
@@ -359,6 +380,24 @@ function withFieldIcon(icon: string | undefined, input: ReactNode): ReactNode {
359
380
  );
360
381
  }
361
382
 
383
+ // Mirrors withFieldIcon on the right side: a muted, non-interactive unit
384
+ // suffix rendered inside the input's visual box. Pure decoration — never
385
+ // focusable, never touches the input's value.
386
+ function withUnitSuffix(unit: string | undefined, input: ReactNode): ReactNode {
387
+ if (unit === undefined) return input;
388
+ return (
389
+ <div className="relative">
390
+ {input}
391
+ <span
392
+ aria-hidden="true"
393
+ className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"
394
+ >
395
+ {unit}
396
+ </span>
397
+ </div>
398
+ );
399
+ }
400
+
362
401
  function DefaultInput(props: InputProps): ReactNode {
363
402
  // Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
364
403
  // selbst — kein manuelles border-destructive mehr nötig.
@@ -409,23 +448,27 @@ function DefaultInput(props: InputProps): ReactNode {
409
448
  />
410
449
  );
411
450
  case "number":
412
- return withFieldIcon(
413
- props.icon,
414
- <UiInput
415
- type="number"
416
- {...common}
417
- data-testid={props.testId}
418
- value={props.value}
419
- step={props.step}
420
- onChange={(e: ChangeEvent<HTMLInputElement>) => {
421
- const v = e.target.value;
422
- props.onChange(v === "" ? undefined : Number(v));
423
- }}
424
- className={cn(
425
- "text-right tabular-nums",
426
- fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined,
427
- )}
428
- />,
451
+ return withUnitSuffix(
452
+ props.unit,
453
+ withFieldIcon(
454
+ props.icon,
455
+ <UiInput
456
+ type="number"
457
+ {...common}
458
+ data-testid={props.testId}
459
+ value={props.value}
460
+ step={props.step}
461
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
462
+ const v = e.target.value;
463
+ props.onChange(v === "" ? undefined : Number(v));
464
+ }}
465
+ className={cn(
466
+ "text-right tabular-nums",
467
+ fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined,
468
+ props.unit !== undefined ? "pr-8" : undefined,
469
+ )}
470
+ />,
471
+ ),
429
472
  );
430
473
  case "range":
431
474
  return (
@@ -682,20 +725,29 @@ function DefaultDataTable({
682
725
  // Optional hooks: a bare DataTable outside LocaleProvider must not crash.
683
726
  const tableTranslate = useOptionalTranslation();
684
727
  const tableLocale = useOptionalLocale();
728
+ // Below 768px a table scrolls its columns out of reach with no visible
729
+ // affordance (fw#2159 fixed the desktop case; narrow viewports never had
730
+ // one). Cards replace the table entirely below the breakpoint — same
731
+ // single-mount pattern as EmbeddedListInput/embedded-list-input.tsx.
732
+ const isNarrow = useIsNarrowViewport();
685
733
  // Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
686
734
  // beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
687
735
  // scrollen typischerweise mit dem Page-Container, nicht intern.
688
736
  // Sticky würde mit der Topbar konkurrieren.
689
737
  const hasTableActions = rowActions !== undefined && rowActions.length > 0;
738
+ const isEmpty = rows.length === 0;
739
+ const emptyBlock: ReactNode = (
740
+ <div
741
+ data-testid={testId !== undefined ? `${testId}-empty` : "render-list-empty"}
742
+ className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-sm text-muted-foreground gap-3"
743
+ >
744
+ {emptyState ?? <span>No entries.</span>}
745
+ </div>
746
+ );
747
+
690
748
  function tableInner(): ReactNode {
691
- return rows.length === 0 ? (
692
- <div
693
- data-testid={testId !== undefined ? `${testId}-empty` : "render-list-empty"}
694
- className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-sm text-muted-foreground gap-3"
695
- >
696
- {emptyState ?? <span>No entries.</span>}
697
- </div>
698
- ) : (
749
+ if (isEmpty) return emptyBlock;
750
+ return (
699
751
  // dashboard-01-Muster: `rounded-lg border`-Rahmen, die Header-Zeile
700
752
  // trägt den bg-muted-Grauton. `bg-card` (statt transparent) → die Liste
701
753
  // sitzt auf derselben Card-Fläche wie Forms; auf Themes mit farbigem
@@ -794,7 +846,147 @@ function DefaultDataTable({
794
846
  </div>
795
847
  );
796
848
  }
797
- const tableContent = tableInner();
849
+
850
+ // No column headers below the breakpoint, so the click-to-sort affordance
851
+ // on SortableHeader has nothing to attach to. A single native <select> is
852
+ // the whole fix — no custom widget, no menu — fed straight from the
853
+ // columns marked sortable in the ViewModel. Options carry the resolved
854
+ // {field, dir} directly so onChange can look the pick up by value instead
855
+ // of parsing/casting the option string back apart.
856
+ const sortableColumns = columns.filter((col) => col.sortable);
857
+ const sortOptions: readonly {
858
+ readonly value: string;
859
+ readonly field: string;
860
+ readonly dir: DataTableSortDir;
861
+ readonly label: string;
862
+ }[] = sortableColumns.flatMap((col) => [
863
+ { value: `${col.field}:asc`, field: col.field, dir: "asc", label: `${col.label} ↑` },
864
+ { value: `${col.field}:desc`, field: col.field, dir: "desc", label: `${col.label} ↓` },
865
+ ]);
866
+
867
+ function renderCard(row: ListRowViewModel): ReactNode {
868
+ const titleColumn = columns.find((col) => col.highlighted === true) ?? columns[0];
869
+ const detailColumns = columns.filter((col) => col !== titleColumn);
870
+ return (
871
+ <div
872
+ key={row.id}
873
+ data-testid={getRowTestId?.(row) ?? `row-${row.id}`}
874
+ {...(onRowClick !== undefined && {
875
+ role: "button" as const,
876
+ tabIndex: 0,
877
+ onClick: () => onRowClick(row),
878
+ onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
879
+ if (e.key !== "Enter" && e.key !== " ") return;
880
+ e.preventDefault();
881
+ onRowClick(row);
882
+ },
883
+ })}
884
+ className={cn(
885
+ "flex flex-col gap-3 rounded-lg border bg-card p-4",
886
+ onRowClick !== undefined && "cursor-pointer",
887
+ )}
888
+ >
889
+ {titleColumn !== undefined && (
890
+ <div
891
+ data-testid={
892
+ getCellTestId?.(row, titleColumn.field) ?? `cell-${row.id}-${titleColumn.field}`
893
+ }
894
+ className="text-base font-medium"
895
+ >
896
+ <DataTableCell
897
+ value={row.values[titleColumn.field]}
898
+ row={row.values}
899
+ field={titleColumn.field}
900
+ type={titleColumn.type}
901
+ renderer={titleColumn.renderer}
902
+ translate={tableTranslate}
903
+ locale={tableLocale}
904
+ {...(titleColumn.optionLabels !== undefined && {
905
+ optionLabels: titleColumn.optionLabels,
906
+ })}
907
+ {...(onCellChange !== undefined && {
908
+ onChange: (value: unknown) => onCellChange(row.id, titleColumn.field, value),
909
+ })}
910
+ />
911
+ </div>
912
+ )}
913
+ <div className="flex flex-col gap-2">
914
+ {detailColumns.map((col) => (
915
+ <div key={col.field} className="flex flex-col gap-0.5">
916
+ <span className="text-xs text-muted-foreground">{col.label}</span>
917
+ <span
918
+ data-testid={getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`}
919
+ className="text-sm"
920
+ >
921
+ <DataTableCell
922
+ value={row.values[col.field]}
923
+ row={row.values}
924
+ field={col.field}
925
+ type={col.type}
926
+ renderer={col.renderer}
927
+ translate={tableTranslate}
928
+ locale={tableLocale}
929
+ {...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
930
+ {...(onCellChange !== undefined && {
931
+ onChange: (value: unknown) => onCellChange(row.id, col.field, value),
932
+ })}
933
+ />
934
+ </span>
935
+ </div>
936
+ ))}
937
+ </div>
938
+ {hasTableActions && (
939
+ // biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation only — not a control
940
+ <div
941
+ className="flex items-center justify-end gap-1 border-t pt-3"
942
+ onClick={(e) => e.stopPropagation()}
943
+ onKeyDown={(e) => e.stopPropagation()}
944
+ >
945
+ <RowActionsCell row={row} actions={rowActions} mode={rowActionMode} />
946
+ </div>
947
+ )}
948
+ </div>
949
+ );
950
+ }
951
+
952
+ function cardsInner(): ReactNode {
953
+ if (isEmpty) return emptyBlock;
954
+ return (
955
+ <div
956
+ data-testid={testId !== undefined ? `${testId}-cards` : "render-list-cards"}
957
+ className="flex flex-col gap-3"
958
+ >
959
+ {onSortChange !== undefined && sortableColumns.length > 0 && (
960
+ <select
961
+ aria-label={tableTranslate?.("kumiko.list.sort.label") ?? "Sort"}
962
+ data-testid={testId !== undefined ? `${testId}-sort` : "render-list-sort"}
963
+ value={sort !== undefined && sort !== null ? `${sort.field}:${sort.dir}` : ""}
964
+ onChange={(e) => {
965
+ const raw = e.target.value;
966
+ if (raw === "") {
967
+ onSortChange(null);
968
+ return;
969
+ }
970
+ const picked = sortOptions.find((o) => o.value === raw);
971
+ if (picked === undefined) return;
972
+ onSortChange({ field: picked.field, dir: picked.dir });
973
+ }}
974
+ className="h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
975
+ >
976
+ <option value="">{tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted"}</option>
977
+ {sortOptions.map((o) => (
978
+ <option key={o.value} value={o.value}>
979
+ {o.label}
980
+ </option>
981
+ ))}
982
+ </select>
983
+ )}
984
+ {rows.map((row) => renderCard(row))}
985
+ </div>
986
+ );
987
+ }
988
+
989
+ const tableContent = isNarrow ? cardsInner() : tableInner();
798
990
 
799
991
  // Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
800
992
  // damit der User bei einem Filter-Hit-of-Zero zurückblättern kann
@@ -1456,11 +1648,30 @@ function isMoneyValue(value: unknown): value is MoneyCellValue {
1456
1648
  // - select/multiSelect → human-readable (kebab-case → Title Case), multiSelect values joined with ", "
1457
1649
  // - money → { amount, currency } formatted via Intl (not "[object Object]")
1458
1650
  // - text/else → toString
1651
+
1652
+ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
1653
+ const warnedTimestampColumns = new Set<string>();
1654
+
1655
+ // A raw ISO string in a "text" column means the author forgot `renderer:
1656
+ // { format: "timestamp" }` — guessing a format here could be wrong, so we
1657
+ // warn and leave the value as-is instead of auto-formatting.
1658
+ function warnMissingTimestampFormat(columnKey: string | undefined): void {
1659
+ if (typeof process === "undefined" || process.env.NODE_ENV === "production") return;
1660
+ const key = columnKey ?? "<unknown column>";
1661
+ if (warnedTimestampColumns.has(key)) return;
1662
+ warnedTimestampColumns.add(key);
1663
+ // biome-ignore lint/suspicious/noConsole: dev-only assertion
1664
+ console.warn(
1665
+ `[kumiko] column "${key}" renders a raw ISO timestamp as text — add renderer: { format: "timestamp" } to its column definition.`,
1666
+ );
1667
+ }
1668
+
1459
1669
  export function defaultCellRender(
1460
1670
  value: unknown,
1461
1671
  type: string,
1462
1672
  optionLabels?: Readonly<Record<string, string>>,
1463
1673
  locale?: string,
1674
+ columnKey?: string,
1464
1675
  ): string {
1465
1676
  if (value === null || value === undefined || value === "") return "";
1466
1677
  if (type === "boolean") return value === true ? "✓" : "";
@@ -1495,6 +1706,9 @@ export function defaultCellRender(
1495
1706
  })
1496
1707
  .join(", ");
1497
1708
  }
1709
+ if (type === "text" && typeof value === "string" && ISO_DATETIME_RE.test(value)) {
1710
+ warnMissingTimestampFormat(columnKey);
1711
+ }
1498
1712
  return typeof value === "string" ? value : String(value);
1499
1713
  }
1500
1714
 
@@ -1603,7 +1817,7 @@ function DataTableCell({
1603
1817
  </Badge>
1604
1818
  );
1605
1819
  }
1606
- return defaultCellRender(value, type, optionLabels, locale);
1820
+ return defaultCellRender(value, type, optionLabels, locale, field);
1607
1821
  }
1608
1822
 
1609
1823
  // ---- Form + Section + Grid + Text ----
@@ -1630,6 +1844,7 @@ function DefaultForm({
1630
1844
  title,
1631
1845
  subtitle,
1632
1846
  actions,
1847
+ secondaryActions,
1633
1848
  testId,
1634
1849
  width,
1635
1850
  stickyActions,
@@ -1653,8 +1868,11 @@ function DefaultForm({
1653
1868
  )}
1654
1869
  >
1655
1870
  <InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
1656
- {actions !== undefined && (
1657
- <div className="flex items-center justify-end gap-2">{actions}</div>
1871
+ {(secondaryActions !== undefined || actions !== undefined) && (
1872
+ <div className="flex items-center justify-end gap-2">
1873
+ {secondaryActions}
1874
+ {actions}
1875
+ </div>
1658
1876
  )}
1659
1877
  </form>
1660
1878
  );
@@ -1714,11 +1932,10 @@ function DefaultForm({
1714
1932
  >
1715
1933
  <InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
1716
1934
  </div>
1717
- {actions !== undefined && (
1935
+ {(secondaryActions !== undefined || actions !== undefined) && (
1718
1936
  <div
1719
- data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1720
1937
  className={cn(
1721
- cardFooter,
1938
+ "flex flex-col-reverse gap-3 px-[var(--card-padding)] py-3 sm:flex-row sm:items-center sm:justify-between sm:py-4",
1722
1939
  cardFooterBorder,
1723
1940
  // Below sm (640px): pin to the viewport bottom instead of normal
1724
1941
  // flow, so a virtual keyboard shrinking the viewport can't push
@@ -1729,7 +1946,22 @@ function DefaultForm({
1729
1946
  "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",
1730
1947
  )}
1731
1948
  >
1732
- {actions}
1949
+ {secondaryActions !== undefined && (
1950
+ <div
1951
+ data-testid={testId !== undefined ? `${testId}-actions-secondary` : undefined}
1952
+ className="flex flex-wrap items-center gap-1 max-sm:[&_button]:text-xs"
1953
+ >
1954
+ {secondaryActions}
1955
+ </div>
1956
+ )}
1957
+ {actions !== undefined && (
1958
+ <div
1959
+ data-testid={testId !== undefined ? `${testId}-actions` : undefined}
1960
+ className="flex items-center gap-2 max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto"
1961
+ >
1962
+ {actions}
1963
+ </div>
1964
+ )}
1733
1965
  </div>
1734
1966
  )}
1735
1967
  </div>