@cosmicdrift/kumiko-renderer-web 0.232.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 +5 -5
- package/src/__tests__/form-action-bar.test.tsx +52 -3
- package/src/__tests__/primitives.test.tsx +63 -12
- package/src/__tests__/render-edit.test.tsx +20 -0
- package/src/__tests__/render-list-narrow-cards.test.tsx +187 -0
- package/src/icons.tsx +116 -0
- package/src/layout/nav-tree.tsx +25 -122
- package/src/primitives/__tests__/data-table-logic.test.ts +33 -0
- package/src/primitives/index.tsx +266 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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.
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1206
|
-
|
|
1207
|
-
const
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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>>;
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
TreeAction,
|
|
18
18
|
TreeNode,
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
-
import type { NavDefinition
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
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"
|
|
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(
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
|
413
|
-
props.
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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">
|
|
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
|
-
|
|
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
|
-
{
|
|
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>
|