@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.262.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.262.0",
20
- "@cosmicdrift/kumiko-headless": "0.262.0",
21
- "@cosmicdrift/kumiko-renderer": "0.262.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.262.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 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">
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
+ });
@@ -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
- "aria-keyshortcuts": "Control+Enter Meta+Enter",
834
+ {...((onSubmitShortcut !== undefined || onKeyDown !== undefined) && {
835
+ ...(onSubmitShortcut !== undefined && {
836
+ "aria-keyshortcuts": "Control+Enter Meta+Enter",
837
+ }),
830
838
  onKeyDown: (e: KeyboardEvent<HTMLTextAreaElement>) => {
831
- if (e.key !== "Enter" || !(e.metaKey || e.ctrlKey)) return;
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({ slots, options, className, testId, children }: CardProps): ReactNode {
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(
@@ -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 "inset-x-0 top-0 h-auto max-h-[80vh] border-b shadow-2xl overflow-hidden";
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 ? { width: effectiveWidthPx, maxWidth: "none" } : customWidthStyle
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