@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.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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.
|
|
43
|
-
"@tiptap/pm": "^3.
|
|
44
|
-
"@tiptap/react": "^3.
|
|
45
|
-
"@tiptap/starter-kit": "^3.
|
|
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.
|
|
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
|
+
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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={
|
|
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
|