@cosmicdrift/kumiko-renderer-web 0.262.0 → 0.264.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__/entity-edit-redirect.test.tsx +2 -0
- package/src/__tests__/kumiko-screen.test.tsx +120 -0
- package/src/__tests__/primitives.test.tsx +9 -0
- package/src/layout/__tests__/shell-header.test.tsx +21 -0
- package/src/layout/shell-header.tsx +8 -1
- package/src/primitives/__tests__/button.test.tsx +9 -0
- package/src/primitives/__tests__/input-data-attributes.test.tsx +45 -0
- package/src/primitives/__tests__/input-keydown.test.tsx +81 -0
- package/src/primitives/__tests__/link-data-attributes.test.tsx +28 -0
- package/src/primitives/index.tsx +24 -4
- package/src/styles.css +16 -0
- package/src/widgets/__tests__/drawer.test.tsx +100 -0
- package/src/widgets/drawer.tsx +35 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.264.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.264.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.264.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.264.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@types/react-dom": "^19.2.3",
|
|
67
67
|
"jsdom": "^29.1.1",
|
|
68
68
|
"tailwindcss": "^4.3.0",
|
|
69
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
69
|
+
"@cosmicdrift/kumiko-locale-de": "0.264.0"
|
|
70
70
|
},
|
|
71
71
|
"repository": {
|
|
72
72
|
"type": "git",
|
|
@@ -60,6 +60,8 @@ function buildParentRedirectSchema(): FeatureSchema {
|
|
|
60
60
|
entity: "product",
|
|
61
61
|
layout: { sections: [{ fields: ["name", "parentId"] }] },
|
|
62
62
|
redirect: { screen: "parent-detail", idFrom: "parentId" },
|
|
63
|
+
// Hand-built schema; production derives urlPrefillFields from declared navigate params.
|
|
64
|
+
urlPrefillFields: ["parentId"],
|
|
63
65
|
};
|
|
64
66
|
const parentDetailScreen: ScreenDefinition = {
|
|
65
67
|
id: "parent-detail",
|
|
@@ -16,8 +16,10 @@ import {
|
|
|
16
16
|
kumikoDefaultTranslations,
|
|
17
17
|
NavProvider,
|
|
18
18
|
UserRolesProvider,
|
|
19
|
+
useNavigateWithInitialValues,
|
|
19
20
|
} from "@cosmicdrift/kumiko-renderer";
|
|
20
21
|
import userEvent from "@testing-library/user-event";
|
|
22
|
+
import { type ReactNode, useState } from "react";
|
|
21
23
|
import { createMockDispatcher, fireEvent, render, screen, waitFor, within } from "./test-utils";
|
|
22
24
|
|
|
23
25
|
const taskEntity = {
|
|
@@ -2610,6 +2612,7 @@ describe("KumikoScreen", () => {
|
|
|
2610
2612
|
layout: {
|
|
2611
2613
|
sections: [{ title: "x", fields: ["title", "priority", "isDone"] }],
|
|
2612
2614
|
},
|
|
2615
|
+
urlPrefillFields: ["title", "priority", "isDone"],
|
|
2613
2616
|
};
|
|
2614
2617
|
|
|
2615
2618
|
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
@@ -2647,6 +2650,7 @@ describe("KumikoScreen", () => {
|
|
|
2647
2650
|
handler: "tasks:write:task:approve",
|
|
2648
2651
|
fields: { priority: { type: "number", default: 7 } },
|
|
2649
2652
|
layout: { sections: [{ title: "x", fields: ["priority"] }] },
|
|
2653
|
+
urlPrefillFields: ["priority"],
|
|
2650
2654
|
};
|
|
2651
2655
|
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
2652
2656
|
render(
|
|
@@ -2662,6 +2666,121 @@ describe("KumikoScreen", () => {
|
|
|
2662
2666
|
expect(priorityInput.value).toBe("7"); // Fallback auf default
|
|
2663
2667
|
});
|
|
2664
2668
|
|
|
2669
|
+
const payoutScreen: ActionFormScreenDefinition = {
|
|
2670
|
+
id: "payout",
|
|
2671
|
+
type: "actionForm",
|
|
2672
|
+
handler: "tasks:write:task:payout",
|
|
2673
|
+
fields: {
|
|
2674
|
+
title: { type: "text", default: "default-title" },
|
|
2675
|
+
iban: { type: "text", default: "own-iban" },
|
|
2676
|
+
secret: { type: "text", sensitive: true },
|
|
2677
|
+
},
|
|
2678
|
+
layout: { sections: [{ title: "x", fields: ["title", "iban", "secret"] }] },
|
|
2679
|
+
urlPrefillFields: ["title", "secret"],
|
|
2680
|
+
};
|
|
2681
|
+
|
|
2682
|
+
function inputValue(fieldName: string): string {
|
|
2683
|
+
const input = screen.getByTestId(`field-${fieldName}`).querySelector("input");
|
|
2684
|
+
if (input === null) throw new Error(`expected an <input> inside field-${fieldName}`);
|
|
2685
|
+
return input.value;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
test("actionForm: a crafted link prefills only urlPrefillFields, never a sensitive one", async () => {
|
|
2689
|
+
const memoryNav = {
|
|
2690
|
+
route: { screenId: "payout" },
|
|
2691
|
+
navigate: () => undefined,
|
|
2692
|
+
replace: () => undefined,
|
|
2693
|
+
hrefFor: () => "/x",
|
|
2694
|
+
searchParams: { title: "declared", iban: "attacker-iban", secret: "leak" },
|
|
2695
|
+
setSearchParams: () => undefined,
|
|
2696
|
+
};
|
|
2697
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
2698
|
+
render(
|
|
2699
|
+
<NavProvider value={memoryNav}>
|
|
2700
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
2701
|
+
<KumikoScreen schema={{ ...schema, screens: [payoutScreen] }} qn="tasks:screen:payout" />
|
|
2702
|
+
</DispatcherProvider>
|
|
2703
|
+
</NavProvider>,
|
|
2704
|
+
);
|
|
2705
|
+
expect(inputValue("title")).toBe("declared");
|
|
2706
|
+
expect(inputValue("iban")).toBe("own-iban");
|
|
2707
|
+
expect(inputValue("secret")).toBe("");
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2710
|
+
test("actionForm: useNavigateWithInitialValues hands values over without touching the query string", async () => {
|
|
2711
|
+
const searchParamWrites: unknown[] = [];
|
|
2712
|
+
function Harness(): ReactNode {
|
|
2713
|
+
const [route, setRoute] = useState<{ screenId: string }>({ screenId: "home" });
|
|
2714
|
+
const nav = {
|
|
2715
|
+
route,
|
|
2716
|
+
navigate: (target: NavTarget) => {
|
|
2717
|
+
if ("screenId" in target) setRoute({ screenId: target.screenId });
|
|
2718
|
+
},
|
|
2719
|
+
replace: () => undefined,
|
|
2720
|
+
hrefFor: () => "/x",
|
|
2721
|
+
searchParams: {},
|
|
2722
|
+
setSearchParams: (u: Record<string, string | null>) => searchParamWrites.push(u),
|
|
2723
|
+
};
|
|
2724
|
+
return (
|
|
2725
|
+
<NavProvider value={nav}>
|
|
2726
|
+
{route.screenId === "payout" ? (
|
|
2727
|
+
<KumikoScreen
|
|
2728
|
+
schema={{ ...schema, screens: [payoutScreen] }}
|
|
2729
|
+
qn="tasks:screen:payout"
|
|
2730
|
+
/>
|
|
2731
|
+
) : (
|
|
2732
|
+
<OpenPayout />
|
|
2733
|
+
)}
|
|
2734
|
+
</NavProvider>
|
|
2735
|
+
);
|
|
2736
|
+
}
|
|
2737
|
+
function OpenPayout(): ReactNode {
|
|
2738
|
+
const navigateWithInitialValues = useNavigateWithInitialValues();
|
|
2739
|
+
return (
|
|
2740
|
+
<button
|
|
2741
|
+
type="button"
|
|
2742
|
+
data-testid="open-payout"
|
|
2743
|
+
onClick={() =>
|
|
2744
|
+
navigateWithInitialValues(
|
|
2745
|
+
{ screenId: "payout" },
|
|
2746
|
+
{ iban: "DE-from-agent", secret: "leak" },
|
|
2747
|
+
)
|
|
2748
|
+
}
|
|
2749
|
+
/>
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
render(
|
|
2753
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
2754
|
+
<Harness />
|
|
2755
|
+
</DispatcherProvider>,
|
|
2756
|
+
);
|
|
2757
|
+
fireEvent.click(screen.getByTestId("open-payout"));
|
|
2758
|
+
await waitFor(() => expect(screen.getByTestId("field-iban")).toBeTruthy());
|
|
2759
|
+
expect(inputValue("iban")).toBe("DE-from-agent");
|
|
2760
|
+
expect(inputValue("secret")).toBe("");
|
|
2761
|
+
expect(searchParamWrites).toEqual([]);
|
|
2762
|
+
});
|
|
2763
|
+
|
|
2764
|
+
test("actionForm: the same values as URL params do not prefill fields outside urlPrefillFields", async () => {
|
|
2765
|
+
const memoryNav = {
|
|
2766
|
+
route: { screenId: "payout" },
|
|
2767
|
+
navigate: () => undefined,
|
|
2768
|
+
replace: () => undefined,
|
|
2769
|
+
hrefFor: () => "/x",
|
|
2770
|
+
searchParams: { iban: "DE-from-agent", agentPrefill: "1" },
|
|
2771
|
+
setSearchParams: () => undefined,
|
|
2772
|
+
};
|
|
2773
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
2774
|
+
render(
|
|
2775
|
+
<NavProvider value={memoryNav}>
|
|
2776
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
2777
|
+
<KumikoScreen schema={{ ...schema, screens: [payoutScreen] }} qn="tasks:screen:payout" />
|
|
2778
|
+
</DispatcherProvider>
|
|
2779
|
+
</NavProvider>,
|
|
2780
|
+
);
|
|
2781
|
+
expect(inputValue("iban")).toBe("own-iban");
|
|
2782
|
+
});
|
|
2783
|
+
|
|
2665
2784
|
test("actionForm submitLabel: i18n-Key landet auf dem Submit-Button (übersteuert default)", () => {
|
|
2666
2785
|
const dispatcher = makeDispatcher();
|
|
2667
2786
|
const actionScreen: ActionFormScreenDefinition = {
|
|
@@ -2960,6 +3079,7 @@ describe("KumikoScreen: actionForm extension-section", () => {
|
|
|
2960
3079
|
{ title: "Update", fields: ["incidentId", "body"] },
|
|
2961
3080
|
],
|
|
2962
3081
|
},
|
|
3082
|
+
urlPrefillFields: ["incidentId"],
|
|
2963
3083
|
};
|
|
2964
3084
|
const UpdateTimeline = ({
|
|
2965
3085
|
initialValues,
|
|
@@ -1860,6 +1860,15 @@ describe("Card", () => {
|
|
|
1860
1860
|
);
|
|
1861
1861
|
expect(screen.getByTestId("c").innerHTML).not.toContain("grow");
|
|
1862
1862
|
});
|
|
1863
|
+
|
|
1864
|
+
test("dataAttributes forwards data-* attributes to the root node", () => {
|
|
1865
|
+
render(
|
|
1866
|
+
<Card testId="c" dataAttributes={{ "data-path": "widgets/entity-card.tsx" }}>
|
|
1867
|
+
body
|
|
1868
|
+
</Card>,
|
|
1869
|
+
);
|
|
1870
|
+
expect(screen.getByTestId("c").getAttribute("data-path")).toBe("widgets/entity-card.tsx");
|
|
1871
|
+
});
|
|
1863
1872
|
});
|
|
1864
1873
|
|
|
1865
1874
|
describe("Section", () => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AppSchema } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { renderWithSidebar, screen } from "../../__tests__/test-utils";
|
|
4
|
+
import { ShellHeader } from "../shell-header";
|
|
5
|
+
|
|
6
|
+
const emptySchema: AppSchema = { features: [] };
|
|
7
|
+
|
|
8
|
+
describe("ShellHeader", () => {
|
|
9
|
+
test("default: h-16, collapses to h-12 with the icon rail (unchanged regression)", () => {
|
|
10
|
+
renderWithSidebar(<ShellHeader schema={emptySchema} />);
|
|
11
|
+
const header = screen.getByRole("banner");
|
|
12
|
+
expect(header.className).toContain("h-16");
|
|
13
|
+
expect(header.className).toContain("group-has-data-[collapsible=icon]/sidebar-wrapper:h-12");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('carries the data-kumiko-layout="shell-header" marker that drives --shell-header-height', () => {
|
|
17
|
+
renderWithSidebar(<ShellHeader schema={emptySchema} />);
|
|
18
|
+
const header = screen.getByRole("banner");
|
|
19
|
+
expect(header.getAttribute("data-kumiko-layout")).toBe("shell-header");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// rechtsbündige headerActions. Geteilt von DefaultAppShell und WorkspaceShell,
|
|
4
4
|
// damit beide dieselbe Kopfzeile tragen (Höhe h-16, kollabiert auf h-12 mit
|
|
5
5
|
// der Icon-Rail).
|
|
6
|
+
//
|
|
7
|
+
// The `data-kumiko-layout="shell-header"` marker drives `--shell-header-height`
|
|
8
|
+
// in styles.css (:has() selector) — the single source for the header height
|
|
9
|
+
// that Drawer's `belowHeader` prop also reads.
|
|
6
10
|
|
|
7
11
|
import type { NavNode } from "@cosmicdrift/kumiko-headless";
|
|
8
12
|
import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
|
|
@@ -57,7 +61,10 @@ export function ShellHeader({
|
|
|
57
61
|
}, [allScreens, screenId, t, tree]);
|
|
58
62
|
|
|
59
63
|
return (
|
|
60
|
-
<header
|
|
64
|
+
<header
|
|
65
|
+
data-kumiko-layout="shell-header"
|
|
66
|
+
className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"
|
|
67
|
+
>
|
|
61
68
|
<div className="flex items-center gap-2 px-4">
|
|
62
69
|
<SidebarTrigger className="-ml-1" />
|
|
63
70
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
|
@@ -24,6 +24,15 @@ describe("DefaultButton className/ref (fw#1831)", () => {
|
|
|
24
24
|
);
|
|
25
25
|
expect(ref.current).toBe(screen.getByTestId("btn"));
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
test("dataAttributes forwards data-* attributes to the <button>", () => {
|
|
29
|
+
render(
|
|
30
|
+
<Button dataAttributes={{ "data-action": "save" }} testId="btn">
|
|
31
|
+
Save
|
|
32
|
+
</Button>,
|
|
33
|
+
);
|
|
34
|
+
expect(screen.getByTestId("btn").getAttribute("data-action")).toBe("save");
|
|
35
|
+
});
|
|
27
36
|
});
|
|
28
37
|
|
|
29
38
|
describe("DefaultButton icon (fw-ui-defaults)", () => {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { defaultPrimitives } from "../index";
|
|
4
|
+
|
|
5
|
+
const { Input } = defaultPrimitives;
|
|
6
|
+
const noop = () => {};
|
|
7
|
+
|
|
8
|
+
describe("Input dataAttributes forwarding", () => {
|
|
9
|
+
test("kind=text: data-* lands on the <input>", () => {
|
|
10
|
+
render(
|
|
11
|
+
<Input
|
|
12
|
+
kind="text"
|
|
13
|
+
id="x"
|
|
14
|
+
name="x"
|
|
15
|
+
value=""
|
|
16
|
+
onChange={noop}
|
|
17
|
+
testId="tid"
|
|
18
|
+
dataAttributes={{ "data-1p-ignore": "true" }}
|
|
19
|
+
/>,
|
|
20
|
+
);
|
|
21
|
+
expect(screen.getByTestId("tid").getAttribute("data-1p-ignore")).toBe("true");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("kind=textarea: data-* lands on the <textarea>", () => {
|
|
25
|
+
const { container } = render(
|
|
26
|
+
<Input
|
|
27
|
+
kind="textarea"
|
|
28
|
+
id="notes"
|
|
29
|
+
name="notes"
|
|
30
|
+
value=""
|
|
31
|
+
onChange={noop}
|
|
32
|
+
dataAttributes={{ "data-testid": "notes-area" }}
|
|
33
|
+
/>,
|
|
34
|
+
);
|
|
35
|
+
const textarea = container.querySelector("textarea");
|
|
36
|
+
expect(textarea?.getAttribute("data-testid")).toBe("notes-area");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("kind=text without dataAttributes: no extra attributes, unchanged default rendering", () => {
|
|
40
|
+
render(<Input kind="text" id="x" name="x" value="" onChange={noop} testId="tid" />);
|
|
41
|
+
const input = screen.getByTestId("tid");
|
|
42
|
+
expect(input.tagName).toBe("INPUT");
|
|
43
|
+
expect(input.getAttribute("data-1p-ignore")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Agent-Panel composer needs "Enter sends, Shift+Enter inserts a newline" at
|
|
2
|
+
// the field itself — InputProps.onKeyDown gives kind="text"/"textarea" raw
|
|
3
|
+
// keydown access instead of forcing that logic onto a wrapping <form>.
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
6
|
+
import { defaultPrimitives } from "../index";
|
|
7
|
+
|
|
8
|
+
const { Input } = defaultPrimitives;
|
|
9
|
+
const noop = () => {};
|
|
10
|
+
|
|
11
|
+
describe("Input onKeyDown", () => {
|
|
12
|
+
test("kind=text: fires with key info on every keydown", () => {
|
|
13
|
+
const events: string[] = [];
|
|
14
|
+
render(
|
|
15
|
+
<Input
|
|
16
|
+
kind="text"
|
|
17
|
+
id="x"
|
|
18
|
+
name="x"
|
|
19
|
+
value=""
|
|
20
|
+
onChange={noop}
|
|
21
|
+
testId="tid"
|
|
22
|
+
onKeyDown={(e) => events.push(e.key)}
|
|
23
|
+
/>,
|
|
24
|
+
);
|
|
25
|
+
fireEvent.keyDown(screen.getByTestId("tid"), { key: "Enter" });
|
|
26
|
+
fireEvent.keyDown(screen.getByTestId("tid"), { key: "a" });
|
|
27
|
+
expect(events).toEqual(["Enter", "a"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("kind=textarea: fires with shiftKey so the caller can distinguish Enter from Shift+Enter", () => {
|
|
31
|
+
const events: Array<{ key: string; shiftKey: boolean }> = [];
|
|
32
|
+
const { container } = render(
|
|
33
|
+
<Input
|
|
34
|
+
kind="textarea"
|
|
35
|
+
id="notes"
|
|
36
|
+
name="notes"
|
|
37
|
+
value=""
|
|
38
|
+
onChange={noop}
|
|
39
|
+
onKeyDown={(e) => events.push({ key: e.key, shiftKey: e.shiftKey })}
|
|
40
|
+
/>,
|
|
41
|
+
);
|
|
42
|
+
const textarea = container.querySelector("textarea");
|
|
43
|
+
if (textarea === null) throw new Error("no textarea rendered");
|
|
44
|
+
fireEvent.keyDown(textarea, { key: "Enter" });
|
|
45
|
+
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true });
|
|
46
|
+
expect(events).toEqual([
|
|
47
|
+
{ key: "Enter", shiftKey: false },
|
|
48
|
+
{ key: "Enter", shiftKey: true },
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("kind=textarea: composes with onSubmitShortcut — both fire on Ctrl+Enter", () => {
|
|
53
|
+
let keyDownCalls = 0;
|
|
54
|
+
let submitCalls = 0;
|
|
55
|
+
const { container } = render(
|
|
56
|
+
<Input
|
|
57
|
+
kind="textarea"
|
|
58
|
+
id="notes"
|
|
59
|
+
name="notes"
|
|
60
|
+
value=""
|
|
61
|
+
onChange={noop}
|
|
62
|
+
onKeyDown={() => {
|
|
63
|
+
keyDownCalls += 1;
|
|
64
|
+
}}
|
|
65
|
+
onSubmitShortcut={() => {
|
|
66
|
+
submitCalls += 1;
|
|
67
|
+
}}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
const textarea = container.querySelector("textarea");
|
|
71
|
+
if (textarea === null) throw new Error("no textarea rendered");
|
|
72
|
+
fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true });
|
|
73
|
+
expect(keyDownCalls).toBe(1);
|
|
74
|
+
expect(submitCalls).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("without onKeyDown: unchanged default rendering (no crash, no listener)", () => {
|
|
78
|
+
render(<Input kind="text" id="x" name="x" value="" onChange={noop} testId="tid" />);
|
|
79
|
+
expect(() => fireEvent.keyDown(screen.getByTestId("tid"), { key: "Enter" })).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { defaultPrimitives } from "../index";
|
|
4
|
+
|
|
5
|
+
const { Link } = defaultPrimitives;
|
|
6
|
+
|
|
7
|
+
describe("DefaultLink", () => {
|
|
8
|
+
test("default rendering unchanged: href + variant class, no data attributes", () => {
|
|
9
|
+
render(
|
|
10
|
+
<Link href="/settings" testId="lnk">
|
|
11
|
+
Settings
|
|
12
|
+
</Link>,
|
|
13
|
+
);
|
|
14
|
+
const link = screen.getByTestId("lnk");
|
|
15
|
+
expect(link.tagName).toBe("A");
|
|
16
|
+
expect(link.getAttribute("href")).toBe("/settings");
|
|
17
|
+
expect(link.className).toContain("text-primary");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("dataAttributes forwards data-* attributes to the <a>, e.g. for a Designer file link", () => {
|
|
21
|
+
render(
|
|
22
|
+
<Link href="#some-file" testId="lnk" dataAttributes={{ "data-path": "some-file.tsx" }}>
|
|
23
|
+
some-file.tsx
|
|
24
|
+
</Link>,
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByTestId("lnk").getAttribute("data-path")).toBe("some-file.tsx");
|
|
27
|
+
});
|
|
28
|
+
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -190,6 +190,7 @@ function DefaultButton({
|
|
|
190
190
|
ref,
|
|
191
191
|
icon,
|
|
192
192
|
iconEnd,
|
|
193
|
+
dataAttributes,
|
|
193
194
|
}: ButtonProps): ReactNode {
|
|
194
195
|
// link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
|
|
195
196
|
// gepolsterte Fläche; width="full" streckt CTA-Buttons in Karten/Panels.
|
|
@@ -217,6 +218,7 @@ function DefaultButton({
|
|
|
217
218
|
type={type}
|
|
218
219
|
onClick={onClick}
|
|
219
220
|
disabled={disabled === true || loading === true}
|
|
221
|
+
{...dataAttributes}
|
|
220
222
|
data-testid={testId}
|
|
221
223
|
data-loading={loading === true ? "true" : undefined}
|
|
222
224
|
variant={BUTTON_VARIANT[variant]}
|
|
@@ -567,10 +569,12 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
567
569
|
<UiInput
|
|
568
570
|
type="text"
|
|
569
571
|
{...common}
|
|
572
|
+
{...props.dataAttributes}
|
|
570
573
|
data-testid={props.testId}
|
|
571
574
|
readOnly={props.readOnly}
|
|
572
575
|
value={props.value}
|
|
573
576
|
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
577
|
+
onKeyDown={props.onKeyDown}
|
|
574
578
|
{...(props.placeholder !== undefined && { placeholder: props.placeholder })}
|
|
575
579
|
{...(props.autoComplete !== undefined && { autoComplete: props.autoComplete })}
|
|
576
580
|
className={cn(fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined)}
|
|
@@ -815,9 +819,11 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
815
819
|
case "textarea": {
|
|
816
820
|
const rows = normalizedTextareaRows(props.rows);
|
|
817
821
|
const onSubmitShortcut = props.onSubmitShortcut;
|
|
822
|
+
const onKeyDown = props.onKeyDown;
|
|
818
823
|
return (
|
|
819
824
|
<Textarea
|
|
820
825
|
{...common}
|
|
826
|
+
{...props.dataAttributes}
|
|
821
827
|
readOnly={props.readOnly}
|
|
822
828
|
value={props.value}
|
|
823
829
|
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => props.onChange(e.target.value)}
|
|
@@ -825,10 +831,14 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
825
831
|
className="resize-y"
|
|
826
832
|
{...(props.placeholder !== undefined && { placeholder: props.placeholder })}
|
|
827
833
|
{...(rows !== undefined && { style: textareaMinHeight(rows) })}
|
|
828
|
-
{...(onSubmitShortcut !== undefined && {
|
|
829
|
-
|
|
834
|
+
{...((onSubmitShortcut !== undefined || onKeyDown !== undefined) && {
|
|
835
|
+
...(onSubmitShortcut !== undefined && {
|
|
836
|
+
"aria-keyshortcuts": "Control+Enter Meta+Enter",
|
|
837
|
+
}),
|
|
830
838
|
onKeyDown: (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
831
|
-
|
|
839
|
+
onKeyDown?.(e);
|
|
840
|
+
if (onSubmitShortcut === undefined || e.key !== "Enter" || !(e.metaKey || e.ctrlKey))
|
|
841
|
+
return;
|
|
832
842
|
e.preventDefault();
|
|
833
843
|
onSubmitShortcut();
|
|
834
844
|
},
|
|
@@ -2782,6 +2792,7 @@ function DefaultLink({
|
|
|
2782
2792
|
className,
|
|
2783
2793
|
children,
|
|
2784
2794
|
testId,
|
|
2795
|
+
dataAttributes,
|
|
2785
2796
|
}: LinkProps): ReactNode {
|
|
2786
2797
|
const variantClass =
|
|
2787
2798
|
variant === "button"
|
|
@@ -2794,6 +2805,7 @@ function DefaultLink({
|
|
|
2794
2805
|
href={isSafeHref(href) ? href : "#"}
|
|
2795
2806
|
target={target}
|
|
2796
2807
|
rel={target === "_blank" ? "noreferrer" : undefined}
|
|
2808
|
+
{...dataAttributes}
|
|
2797
2809
|
data-testid={testId}
|
|
2798
2810
|
className={cn(variantClass, className)}
|
|
2799
2811
|
>
|
|
@@ -2859,7 +2871,14 @@ import { ConfigSourceBadge as DefaultConfigSourceBadge } from "../components/con
|
|
|
2859
2871
|
|
|
2860
2872
|
// Generische Card-Chrome (rounded-xl wie die Entity-Card) — slot- + options-
|
|
2861
2873
|
// basiert, damit der Contract additiv wächst und Consumer nie migriert werden.
|
|
2862
|
-
export function DefaultCard({
|
|
2874
|
+
export function DefaultCard({
|
|
2875
|
+
slots,
|
|
2876
|
+
options,
|
|
2877
|
+
className,
|
|
2878
|
+
testId,
|
|
2879
|
+
dataAttributes,
|
|
2880
|
+
children,
|
|
2881
|
+
}: CardProps): ReactNode {
|
|
2863
2882
|
const padded = options?.padded ?? true;
|
|
2864
2883
|
const radius = options?.radius ?? "xl";
|
|
2865
2884
|
const footerBordered = options?.footerBordered ?? true;
|
|
@@ -2883,6 +2902,7 @@ export function DefaultCard({ slots, options, className, testId, children }: Car
|
|
|
2883
2902
|
return (
|
|
2884
2903
|
<div
|
|
2885
2904
|
data-slot="card"
|
|
2905
|
+
{...dataAttributes}
|
|
2886
2906
|
data-testid={testId}
|
|
2887
2907
|
className={cn(cardSurface({ radius }), "overflow-hidden", className)}
|
|
2888
2908
|
>
|
package/src/styles.css
CHANGED
|
@@ -110,6 +110,22 @@
|
|
|
110
110
|
--card-radius: var(--radius-xl);
|
|
111
111
|
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
|
112
112
|
--kumiko-grid-row-h: 2.5rem;
|
|
113
|
+
/* 0 = no ShellHeader mounted (e.g. a Drawer used outside a shell) — a
|
|
114
|
+
consumer offsetting by this variable then gets today's edge-to-edge
|
|
115
|
+
behavior for free. */
|
|
116
|
+
--shell-header-height: 0px;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* Mirrors ShellHeader's own h-16/h-12 (icon-rail collapsed) via the same
|
|
120
|
+
[data-collapsible=icon] state Tailwind's group-has-data variant reads —
|
|
121
|
+
kept in CSS (not a React state) so a Drawer rendered into a portal
|
|
122
|
+
(outside ShellHeader's DOM subtree) still resolves the right value from
|
|
123
|
+
this shared :root ancestor. */
|
|
124
|
+
:root:has([data-kumiko-layout="shell-header"]) {
|
|
125
|
+
--shell-header-height: 4rem;
|
|
126
|
+
}
|
|
127
|
+
:root:has([data-kumiko-layout="shell-header"]):has([data-collapsible="icon"]) {
|
|
128
|
+
--shell-header-height: 3rem;
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
/* Light-Mode-Overrides — aktiv wenn `<html>` KEINE `.dark` Klasse
|
|
@@ -151,6 +151,87 @@ describe("Drawer", () => {
|
|
|
151
151
|
});
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
describe("belowHeader", () => {
|
|
155
|
+
test("default (false): variant=flush keeps inset-y-0, no header offset", () => {
|
|
156
|
+
render(
|
|
157
|
+
<Drawer open={true} onOpenChange={() => {}} side="right" variant="flush" testId="drawer">
|
|
158
|
+
<div>Body</div>
|
|
159
|
+
</Drawer>,
|
|
160
|
+
);
|
|
161
|
+
const content = screen.getByTestId("drawer");
|
|
162
|
+
expect(content.className).toContain("inset-y-0");
|
|
163
|
+
expect(content.style.top).toBe("");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("true + variant=flush + side=right: offsets top by the header var via inline style, sticks to bottom", () => {
|
|
167
|
+
render(
|
|
168
|
+
<Drawer
|
|
169
|
+
open={true}
|
|
170
|
+
onOpenChange={() => {}}
|
|
171
|
+
side="right"
|
|
172
|
+
variant="flush"
|
|
173
|
+
belowHeader
|
|
174
|
+
testId="drawer"
|
|
175
|
+
>
|
|
176
|
+
<div>Body</div>
|
|
177
|
+
</Drawer>,
|
|
178
|
+
);
|
|
179
|
+
const content = screen.getByTestId("drawer");
|
|
180
|
+
// Inline style (not a class) — className keeps `inset-y-0` since
|
|
181
|
+
// tailwind-merge doesn't dedupe it against `top-*`/`bottom-*`; style
|
|
182
|
+
// wins over the class regardless, so this is the reliable assertion.
|
|
183
|
+
expect(content.style.top).toBe("var(--shell-header-height)");
|
|
184
|
+
expect(content.style.bottom).toBe("0px");
|
|
185
|
+
expect(content.className).toContain("inset-y-0");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("true + variant=flush + side=top: top offset instead of top-0", () => {
|
|
189
|
+
render(
|
|
190
|
+
<Drawer
|
|
191
|
+
open={true}
|
|
192
|
+
onOpenChange={() => {}}
|
|
193
|
+
side="top"
|
|
194
|
+
variant="flush"
|
|
195
|
+
belowHeader
|
|
196
|
+
testId="drawer"
|
|
197
|
+
>
|
|
198
|
+
<div>Body</div>
|
|
199
|
+
</Drawer>,
|
|
200
|
+
);
|
|
201
|
+
const content = screen.getByTestId("drawer");
|
|
202
|
+
expect(content.className).toContain("top-(--shell-header-height)");
|
|
203
|
+
expect(content.className).not.toContain("top-0");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("true + variant=flush + side=bottom: unaffected (still bottom-anchored)", () => {
|
|
207
|
+
render(
|
|
208
|
+
<Drawer
|
|
209
|
+
open={true}
|
|
210
|
+
onOpenChange={() => {}}
|
|
211
|
+
side="bottom"
|
|
212
|
+
variant="flush"
|
|
213
|
+
belowHeader
|
|
214
|
+
testId="drawer"
|
|
215
|
+
>
|
|
216
|
+
<div>Body</div>
|
|
217
|
+
</Drawer>,
|
|
218
|
+
);
|
|
219
|
+
expect(screen.getByTestId("drawer").className).toContain("bottom-0");
|
|
220
|
+
expect(screen.getByTestId("drawer").className).not.toContain("--shell-header-height");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("true + variant=floating (default variant): ignored, floating classes unchanged", () => {
|
|
224
|
+
render(
|
|
225
|
+
<Drawer open={true} onOpenChange={() => {}} side="right" belowHeader testId="drawer">
|
|
226
|
+
<div>Body</div>
|
|
227
|
+
</Drawer>,
|
|
228
|
+
);
|
|
229
|
+
const content = screen.getByTestId("drawer");
|
|
230
|
+
expect(content.className).toContain("inset-y-8");
|
|
231
|
+
expect(content.className).not.toContain("--shell-header-height");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
154
235
|
describe("width", () => {
|
|
155
236
|
test("width={420}: reflected as inline pixel width", () => {
|
|
156
237
|
render(
|
|
@@ -570,6 +651,25 @@ describe("Drawer", () => {
|
|
|
570
651
|
expect(screen.queryByRole("button", { name: /maximize drawer width/i })).toBeNull();
|
|
571
652
|
});
|
|
572
653
|
|
|
654
|
+
test("narrow with belowHeader: fullscreen branch unaffected (already covers the header)", () => {
|
|
655
|
+
mockMatchMedia(true);
|
|
656
|
+
render(
|
|
657
|
+
<Drawer
|
|
658
|
+
open={true}
|
|
659
|
+
onOpenChange={() => {}}
|
|
660
|
+
side="right"
|
|
661
|
+
variant="flush"
|
|
662
|
+
belowHeader
|
|
663
|
+
testId="drawer"
|
|
664
|
+
>
|
|
665
|
+
<div>Body</div>
|
|
666
|
+
</Drawer>,
|
|
667
|
+
);
|
|
668
|
+
const content = screen.getByTestId("drawer");
|
|
669
|
+
expect(content.className).toContain("inset-0");
|
|
670
|
+
expect(content.className).not.toContain("--shell-header-height");
|
|
671
|
+
});
|
|
672
|
+
|
|
573
673
|
test("wide (regression clamp): inline width and handle still present with resize set", () => {
|
|
574
674
|
mockMatchMedia(false);
|
|
575
675
|
render(
|
package/src/widgets/drawer.tsx
CHANGED
|
@@ -34,6 +34,13 @@ export type DrawerProps = {
|
|
|
34
34
|
* only on the edge facing the app content. Ignored in the narrow-viewport
|
|
35
35
|
* fullscreen layout. */
|
|
36
36
|
readonly variant?: "floating" | "flush";
|
|
37
|
+
/** `variant="flush"` only: dock the panel below the app's ShellHeader
|
|
38
|
+
* (offset top by `--shell-header-height`, height shrunk to match)
|
|
39
|
+
* instead of covering it. Default `false` keeps today's edge-to-edge
|
|
40
|
+
* behavior. No ShellHeader mounted → the variable is `0`, so this is a
|
|
41
|
+
* no-op. Ignored in the narrow-viewport fullscreen layout, which already
|
|
42
|
+
* takes over the whole screen including the header. */
|
|
43
|
+
readonly belowHeader?: boolean;
|
|
37
44
|
/** Panel width for `side="left"|"right"` (ignored for top/bottom and in
|
|
38
45
|
* the narrow-viewport layout). A number is pixels, a string any CSS
|
|
39
46
|
* length. Superseded by `resize` when that's set. Default matches the
|
|
@@ -96,14 +103,22 @@ function sidePanelClass(
|
|
|
96
103
|
side: "left" | "right" | "top" | "bottom",
|
|
97
104
|
narrow: boolean,
|
|
98
105
|
variant: "floating" | "flush",
|
|
106
|
+
belowHeader: boolean,
|
|
99
107
|
): string {
|
|
100
108
|
if (narrow) return "inset-0 h-full w-full max-w-none rounded-none border-0 overflow-hidden";
|
|
101
109
|
if (variant === "flush") {
|
|
102
110
|
switch (side) {
|
|
111
|
+
// left/right keep the `inset-y-0` class here even when belowHeader is
|
|
112
|
+
// set — DrawerSheetContent's own base className already carries
|
|
113
|
+
// `inset-y-0` for these sides, and tailwind-merge (v3.6, checked
|
|
114
|
+
// directly) does NOT dedupe `top-*`/`bottom-*` against it (unlike
|
|
115
|
+
// same-group `top-0` vs `top-(--x)`, which it does). Overriding top
|
|
116
|
+
// via inline `style` instead (Drawer's `verticalOffsetStyle`) always
|
|
117
|
+
// wins over both, without depending on that gap.
|
|
103
118
|
case "left":
|
|
104
119
|
return `inset-y-0 left-0 h-full ${WIDTH_CLASS} border-r shadow-2xl overflow-hidden`;
|
|
105
120
|
case "top":
|
|
106
|
-
return
|
|
121
|
+
return `inset-x-0 ${belowHeader ? "top-(--shell-header-height)" : "top-0"} h-auto max-h-[80vh] border-b shadow-2xl overflow-hidden`;
|
|
107
122
|
case "bottom":
|
|
108
123
|
return "inset-x-0 bottom-0 h-auto max-h-[80vh] border-t shadow-2xl overflow-hidden";
|
|
109
124
|
default:
|
|
@@ -136,6 +151,7 @@ export function Drawer({
|
|
|
136
151
|
testId,
|
|
137
152
|
showCloseButton = true,
|
|
138
153
|
variant = "floating",
|
|
154
|
+
belowHeader = false,
|
|
139
155
|
width,
|
|
140
156
|
resize,
|
|
141
157
|
backdrop,
|
|
@@ -167,6 +183,17 @@ export function Drawer({
|
|
|
167
183
|
|
|
168
184
|
const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : resizedWidthPx;
|
|
169
185
|
|
|
186
|
+
// Overrides the base className's `inset-y-0` (top:0) as inline style —
|
|
187
|
+
// tailwind-merge doesn't dedupe `top-*`/`bottom-*` against `inset-y-*`
|
|
188
|
+
// (verified directly against the pinned tailwind-merge), so a class-only
|
|
189
|
+
// override would leave both `inset-y-0` and the offset in the className,
|
|
190
|
+
// with the winner then depending on Tailwind's generated CSS order. style
|
|
191
|
+
// always wins over a class for the same property, no such dependency.
|
|
192
|
+
const verticalOffsetStyle: React.CSSProperties | undefined =
|
|
193
|
+
belowHeader && variant === "flush" && !narrow && (side === "left" || side === "right")
|
|
194
|
+
? { top: "var(--shell-header-height)", bottom: 0 }
|
|
195
|
+
: undefined;
|
|
196
|
+
|
|
170
197
|
const onHandlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
171
198
|
event.preventDefault();
|
|
172
199
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
@@ -216,10 +243,13 @@ export function Drawer({
|
|
|
216
243
|
data-testid={testId}
|
|
217
244
|
overlayStyle={overlayStyle}
|
|
218
245
|
showCloseButton={showCloseButton}
|
|
219
|
-
className={sidePanelClass(side, narrow, variant)}
|
|
220
|
-
style={
|
|
221
|
-
canResize && !narrow
|
|
222
|
-
|
|
246
|
+
className={sidePanelClass(side, narrow, variant, belowHeader)}
|
|
247
|
+
style={{
|
|
248
|
+
...(canResize && !narrow
|
|
249
|
+
? { width: effectiveWidthPx, maxWidth: "none" }
|
|
250
|
+
: customWidthStyle),
|
|
251
|
+
...verticalOffsetStyle,
|
|
252
|
+
}}
|
|
223
253
|
>
|
|
224
254
|
{canResize && !narrow && (
|
|
225
255
|
<button
|