@cosmicdrift/kumiko-renderer-web 0.241.0 → 0.243.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.241.0",
3
+ "version": "0.243.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.241.0",
20
- "@cosmicdrift/kumiko-headless": "0.241.0",
21
- "@cosmicdrift/kumiko-renderer": "0.241.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.243.0",
20
+ "@cosmicdrift/kumiko-headless": "0.243.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.243.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",
@@ -39,10 +39,10 @@
39
39
  "react-resizable-panels": "^4.12.2",
40
40
  "tailwind-merge": "^3.6.0",
41
41
  "temporal-polyfill": "^0.3.2",
42
- "@tiptap/core": "^3.29.2",
43
- "@tiptap/pm": "^3.29.2",
44
- "@tiptap/react": "^3.29.2",
45
- "@tiptap/starter-kit": "^3.29.2"
42
+ "@tiptap/core": "^3.30.5",
43
+ "@tiptap/pm": "^3.30.5",
44
+ "@tiptap/react": "^3.30.5",
45
+ "@tiptap/starter-kit": "^3.30.5"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@tailwindcss/cli": "^4.3.0",
@@ -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.241.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.243.0"
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
@@ -1889,6 +1889,114 @@ describe("KumikoScreen", () => {
1889
1889
  expect(navigateCalls[0]).toEqual({ screenId: "task-edit", entityId: "x" });
1890
1890
  });
1891
1891
 
1892
+ // #2670: a handler that writes a child record reports the child's id, but
1893
+ // the redirect target is the parent's detail screen — `idFrom` names the
1894
+ // payload field the navigation id comes from, so the handler keeps
1895
+ // reporting what it actually wrote.
1896
+ test("actionForm mit redirect-Objekt + idFrom: navigiert mit der Eltern-ID statt data.id (#2670)", async () => {
1897
+ const navigateCalls: NavTarget[] = [];
1898
+ const dispatcher = makeDispatcher({
1899
+ write: (async () => ({
1900
+ isSuccess: true,
1901
+ data: { id: "child", leaseId: "parent" },
1902
+ })) as unknown as Dispatcher["write"],
1903
+ });
1904
+ const memoryNav = {
1905
+ route: { screenId: "quick-add" },
1906
+ navigate: (target: NavTarget) => {
1907
+ if ("screenId" in target) navigateCalls.push(target);
1908
+ },
1909
+ replace: () => undefined,
1910
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1911
+ searchParams: {},
1912
+ setSearchParams: () => undefined,
1913
+ };
1914
+ const editScreen: EntityEditScreenDefinition = {
1915
+ id: "task-edit",
1916
+ type: "entityEdit",
1917
+ entity: "task",
1918
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
1919
+ };
1920
+ const actionScreen: ActionFormScreenDefinition = {
1921
+ id: "quick-add",
1922
+ type: "actionForm",
1923
+ handler: "tasks:write:task:quick-add",
1924
+ fields: { title: { type: "text", required: true } },
1925
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
1926
+ redirect: { screen: "task-edit", idFrom: "leaseId" },
1927
+ };
1928
+
1929
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
1930
+ render(
1931
+ <NavProvider value={memoryNav}>
1932
+ <DispatcherProvider dispatcher={dispatcher}>
1933
+ <KumikoScreen
1934
+ schema={{ ...schema, screens: [actionScreen, editScreen, listScreen] }}
1935
+ qn="tasks:screen:quick-add"
1936
+ />
1937
+ </DispatcherProvider>
1938
+ </NavProvider>,
1939
+ );
1940
+
1941
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1942
+ fireEvent.change(titleInput, { target: { value: "go" } });
1943
+ await clickSubmitOnceEnabled();
1944
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
1945
+ expect(navigateCalls[0]).toEqual({ screenId: "task-edit", entityId: "parent" });
1946
+ });
1947
+
1948
+ test("actionForm mit blankem String-redirect: navigiert weiter mit data.id, nicht mit einem anderen Payload-Feld (#2670)", async () => {
1949
+ const navigateCalls: NavTarget[] = [];
1950
+ const dispatcher = makeDispatcher({
1951
+ write: (async () => ({
1952
+ isSuccess: true,
1953
+ data: { id: "child", leaseId: "parent" },
1954
+ })) as unknown as Dispatcher["write"],
1955
+ });
1956
+ const memoryNav = {
1957
+ route: { screenId: "quick-add" },
1958
+ navigate: (target: NavTarget) => {
1959
+ if ("screenId" in target) navigateCalls.push(target);
1960
+ },
1961
+ replace: () => undefined,
1962
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1963
+ searchParams: {},
1964
+ setSearchParams: () => undefined,
1965
+ };
1966
+ const editScreen: EntityEditScreenDefinition = {
1967
+ id: "task-edit",
1968
+ type: "entityEdit",
1969
+ entity: "task",
1970
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
1971
+ };
1972
+ const actionScreen: ActionFormScreenDefinition = {
1973
+ id: "quick-add",
1974
+ type: "actionForm",
1975
+ handler: "tasks:write:task:quick-add",
1976
+ fields: { title: { type: "text", required: true } },
1977
+ layout: { sections: [{ title: "x", fields: ["title"] }] },
1978
+ redirect: "task-edit",
1979
+ };
1980
+
1981
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
1982
+ render(
1983
+ <NavProvider value={memoryNav}>
1984
+ <DispatcherProvider dispatcher={dispatcher}>
1985
+ <KumikoScreen
1986
+ schema={{ ...schema, screens: [actionScreen, editScreen, listScreen] }}
1987
+ qn="tasks:screen:quick-add"
1988
+ />
1989
+ </DispatcherProvider>
1990
+ </NavProvider>,
1991
+ );
1992
+
1993
+ const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
1994
+ fireEvent.change(titleInput, { target: { value: "go" } });
1995
+ await clickSubmitOnceEnabled();
1996
+ await waitFor(() => expect(navigateCalls.length).toBe(1));
1997
+ expect(navigateCalls[0]).toEqual({ screenId: "task-edit", entityId: "child" });
1998
+ });
1999
+
1892
2000
  // #2416: entityEdit-create passes the newly created record's id through to
1893
2001
  // the post-submit navigate as `entityId` (extractCreatedId); actionForm's
1894
2002
  // redirect did the same navigate but never extracted the id. The with-id
@@ -0,0 +1,58 @@
1
+ // #2677 — `field-sizing: content` on the vendored shadcn Textarea makes the
2
+ // `rows` attribute inert, so a declared row count has to reach the rendered
3
+ // height as a min-height floor. Asserting the attribute alone would pass even
4
+ // with the bug present.
5
+ import { describe, expect, test } from "bun:test";
6
+ import { render } from "@testing-library/react";
7
+ import { defaultPrimitives } from "../index";
8
+
9
+ const { Input } = defaultPrimitives;
10
+ const noop = () => {};
11
+
12
+ function renderTextarea(rows?: number): HTMLTextAreaElement {
13
+ const { container } = render(
14
+ <Input
15
+ kind="textarea"
16
+ id="notes"
17
+ name="notes"
18
+ value=""
19
+ onChange={noop}
20
+ {...(rows !== undefined && { rows })}
21
+ />,
22
+ );
23
+ const textarea = container.querySelector("textarea");
24
+ if (textarea === null) throw new Error("no textarea rendered");
25
+ return textarea;
26
+ }
27
+
28
+ function rowFactor(minHeight: string): number {
29
+ const match = /calc\((\d+(?:\.\d+)?) \* 1lh/.exec(minHeight);
30
+ if (match?.[1] === undefined) throw new Error(`min-height not row-derived: "${minHeight}"`);
31
+ return Number(match[1]);
32
+ }
33
+
34
+ describe("textarea rows → min-height (#2677)", () => {
35
+ test("declared rows drive the min-height, not just the attribute", () => {
36
+ const textarea = renderTextarea(16);
37
+ expect(textarea.getAttribute("rows")).toBe("16");
38
+ expect(rowFactor(textarea.style.minHeight)).toBe(16);
39
+ });
40
+
41
+ test("min-height scales with the declared row count", () => {
42
+ const small = rowFactor(renderTextarea(4).style.minHeight);
43
+ const large = rowFactor(renderTextarea(16).style.minHeight);
44
+ expect(large).toBeGreaterThan(small);
45
+ });
46
+
47
+ test("without rows no min-height is imposed — the Tailwind floor stays", () => {
48
+ expect(renderTextarea().style.minHeight).toBe("");
49
+ });
50
+
51
+ test("a row count that is not a positive integer falls back to the default", () => {
52
+ for (const rows of [0, -3, 2.5]) {
53
+ const textarea = renderTextarea(rows);
54
+ expect(textarea.style.minHeight).toBe("");
55
+ expect(textarea.getAttribute("rows")).toBe("4");
56
+ }
57
+ });
58
+ });
@@ -528,6 +528,22 @@ function SegmentedSelect({
528
528
  );
529
529
  }
530
530
 
531
+ // The row count reaches the textarea through an untyped view-model hint, so a
532
+ // sloppy schema can hand over a fraction or a zero — neither renders a sane
533
+ // attribute nor a sane min-height, so both fall back to the default instead.
534
+ function normalizedTextareaRows(rows: number | undefined): number | undefined {
535
+ return rows !== undefined && Number.isInteger(rows) && rows >= 1 ? rows : undefined;
536
+ }
537
+
538
+ // The vendored shadcn Textarea carries `field-sizing: content`, which derives
539
+ // the box height from the content and makes the `rows` attribute inert (#2677).
540
+ // A declared row count therefore only survives as a min-height floor: the field
541
+ // starts at `rows` lines tall and still grows with its content. The addend is
542
+ // the textarea frame — py-2 top+bottom plus the 1px borders.
543
+ function textareaMinHeight(rows: number): CSSProperties {
544
+ return { minHeight: `calc(${rows} * 1lh + 1rem + 2px)` };
545
+ }
546
+
531
547
  function DefaultInput(props: InputProps): ReactNode {
532
548
  // Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
533
549
  // selbst — kein manuelles border-destructive mehr nötig.
@@ -783,17 +799,20 @@ function DefaultInput(props: InputProps): ReactNode {
783
799
  {...(props.hasError !== undefined && { hasError: props.hasError })}
784
800
  />
785
801
  );
786
- case "textarea":
802
+ case "textarea": {
803
+ const rows = normalizedTextareaRows(props.rows);
787
804
  return (
788
805
  <Textarea
789
806
  {...common}
790
807
  readOnly={props.readOnly}
791
808
  value={props.value}
792
809
  onChange={(e: ChangeEvent<HTMLTextAreaElement>) => props.onChange(e.target.value)}
793
- rows={props.rows ?? 4}
810
+ rows={rows ?? 4}
794
811
  className="resize-y"
812
+ {...(rows !== undefined && { style: textareaMinHeight(rows) })}
795
813
  />
796
814
  );
815
+ }
797
816
  case "tz":
798
817
  return (
799
818
  <TzInput