@cosmicdrift/kumiko-renderer 0.180.0 → 0.182.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",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.182.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.182.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.182.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2"
|
|
22
22
|
},
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import type { ComponentType, ReactNode } from "react";
|
|
4
|
+
import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
|
|
5
|
+
import {
|
|
6
|
+
CONTENT_EDITOR_ELEMENT_ID,
|
|
7
|
+
type ContentEditorProps,
|
|
8
|
+
ContentEditorsProvider,
|
|
9
|
+
TextareaContentEditor,
|
|
10
|
+
useContentEditor,
|
|
11
|
+
} from "../content-editors";
|
|
12
|
+
|
|
13
|
+
const captureInput: ComponentType<InputProps> = (props) => {
|
|
14
|
+
if (props.kind !== "textarea") return null;
|
|
15
|
+
return (
|
|
16
|
+
<textarea
|
|
17
|
+
data-testid="ca-textarea"
|
|
18
|
+
id={props.id}
|
|
19
|
+
value={props.value}
|
|
20
|
+
disabled={props.disabled}
|
|
21
|
+
readOnly
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const noop = (): ReactNode => null;
|
|
27
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
28
|
+
|
|
29
|
+
const testPrimitives: CorePrimitives = {
|
|
30
|
+
Button: noop,
|
|
31
|
+
Banner: passChildren,
|
|
32
|
+
Field: passChildren,
|
|
33
|
+
Input: captureInput,
|
|
34
|
+
DataTable: noop,
|
|
35
|
+
Form: passChildren,
|
|
36
|
+
Section: passChildren,
|
|
37
|
+
Card: passChildren,
|
|
38
|
+
Grid: passChildren,
|
|
39
|
+
GridCell: passChildren,
|
|
40
|
+
Text: passChildren,
|
|
41
|
+
Heading: noop,
|
|
42
|
+
Dialog: noop,
|
|
43
|
+
Modal: noop,
|
|
44
|
+
Lightbox: noop,
|
|
45
|
+
ConfigSourceBadge: noop,
|
|
46
|
+
ConfigCascadeView: noop,
|
|
47
|
+
Link: noop,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function RichEditor({ value }: ContentEditorProps): ReactNode {
|
|
51
|
+
return <div data-testid="ca-rich">{value}</div>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
55
|
+
return <PrimitivesProvider value={testPrimitives}>{children}</PrimitivesProvider>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Probe({ contentFormat }: { readonly contentFormat?: string }): ReactNode {
|
|
59
|
+
const Editor = useContentEditor(contentFormat);
|
|
60
|
+
return <Editor value="hello" onChange={() => {}} variables={[]} readOnly={false} />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("useContentEditor", () => {
|
|
64
|
+
test("no editor registered for the format → falls back to the textarea", () => {
|
|
65
|
+
render(<Probe contentFormat="plain" />, { wrapper: Wrapper });
|
|
66
|
+
expect(screen.getByTestId("ca-textarea")).toBeTruthy();
|
|
67
|
+
expect(screen.getByTestId("ca-textarea").id).toBe(CONTENT_EDITOR_ELEMENT_ID);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("undefined contentFormat behaves like plain", () => {
|
|
71
|
+
render(<Probe />, { wrapper: Wrapper });
|
|
72
|
+
expect(screen.getByTestId("ca-textarea")).toBeTruthy();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("an app-registered editor for the format wins over the textarea fallback", () => {
|
|
76
|
+
render(
|
|
77
|
+
<ContentEditorsProvider value={{ rich: RichEditor }}>
|
|
78
|
+
<Probe contentFormat="rich" />
|
|
79
|
+
</ContentEditorsProvider>,
|
|
80
|
+
{ wrapper: Wrapper },
|
|
81
|
+
);
|
|
82
|
+
expect(screen.getByTestId("ca-rich").textContent).toBe("hello");
|
|
83
|
+
expect(screen.queryByTestId("ca-textarea")).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a registered editor only applies to its own format — other formats stay on the fallback", () => {
|
|
87
|
+
render(
|
|
88
|
+
<ContentEditorsProvider value={{ rich: RichEditor }}>
|
|
89
|
+
<Probe contentFormat="plain" />
|
|
90
|
+
</ContentEditorsProvider>,
|
|
91
|
+
{ wrapper: Wrapper },
|
|
92
|
+
);
|
|
93
|
+
expect(screen.getByTestId("ca-textarea")).toBeTruthy();
|
|
94
|
+
expect(screen.queryByTestId("ca-rich")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("TextareaContentEditor", () => {
|
|
99
|
+
test("passes value + disabled=readOnly through to the primitives Input", () => {
|
|
100
|
+
render(
|
|
101
|
+
<TextareaContentEditor value="draft" onChange={() => {}} variables={[]} readOnly={true} />,
|
|
102
|
+
{ wrapper: Wrapper },
|
|
103
|
+
);
|
|
104
|
+
const el = screen.getByTestId("ca-textarea") as HTMLTextAreaElement;
|
|
105
|
+
expect(el.value).toBe("draft");
|
|
106
|
+
expect(el.disabled).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import type { ComponentType, ReactNode } from "react";
|
|
4
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
5
|
+
import { type ButtonProps, type CorePrimitives, PrimitivesProvider } from "../../primitives";
|
|
6
|
+
import { VariableChips } from "../variable-chips";
|
|
7
|
+
|
|
8
|
+
const captureButton: ComponentType<ButtonProps> = ({ children, onClick, ariaLabel, disabled }) => (
|
|
9
|
+
<button type="button" aria-label={ariaLabel} onClick={() => void onClick?.()} disabled={disabled}>
|
|
10
|
+
{children}
|
|
11
|
+
</button>
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const noop = (): ReactNode => null;
|
|
15
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
16
|
+
|
|
17
|
+
const testPrimitives: CorePrimitives = {
|
|
18
|
+
Button: captureButton,
|
|
19
|
+
Banner: passChildren,
|
|
20
|
+
Field: passChildren,
|
|
21
|
+
Input: noop,
|
|
22
|
+
DataTable: noop,
|
|
23
|
+
Form: passChildren,
|
|
24
|
+
Section: passChildren,
|
|
25
|
+
Card: passChildren,
|
|
26
|
+
Grid: passChildren,
|
|
27
|
+
GridCell: passChildren,
|
|
28
|
+
Text: passChildren,
|
|
29
|
+
Heading: noop,
|
|
30
|
+
Dialog: noop,
|
|
31
|
+
Modal: noop,
|
|
32
|
+
Lightbox: noop,
|
|
33
|
+
ConfigSourceBadge: noop,
|
|
34
|
+
ConfigCascadeView: noop,
|
|
35
|
+
Link: noop,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
39
|
+
return (
|
|
40
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "en" })}>
|
|
41
|
+
<PrimitivesProvider value={testPrimitives}>{children}</PrimitivesProvider>
|
|
42
|
+
</LocaleProvider>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("VariableChips", () => {
|
|
47
|
+
test("renders no chips when there are no variables", () => {
|
|
48
|
+
render(<VariableChips variables={[]} onInsert={() => {}} />, { wrapper: Wrapper });
|
|
49
|
+
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("renders one chip per variable and reports a click by name", () => {
|
|
53
|
+
const inserted: string[] = [];
|
|
54
|
+
render(
|
|
55
|
+
<VariableChips
|
|
56
|
+
variables={["customerName", "orderId"]}
|
|
57
|
+
onInsert={(name) => inserted.push(name)}
|
|
58
|
+
/>,
|
|
59
|
+
{ wrapper: Wrapper },
|
|
60
|
+
);
|
|
61
|
+
expect(screen.getAllByRole("button")).toHaveLength(2);
|
|
62
|
+
fireEvent.click(screen.getByText("{{orderId}}"));
|
|
63
|
+
expect(inserted).toEqual(["orderId"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("disabled propagates to every chip", () => {
|
|
67
|
+
render(<VariableChips variables={["a"]} onInsert={() => {}} disabled />, { wrapper: Wrapper });
|
|
68
|
+
expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Content-Editor-Components-Map: client-side lookup by `contentFormat`
|
|
2
|
+
// ("plain" | "rich" | "markdown") for r.contentCollection() entries. A collection
|
|
3
|
+
// declares its contentFormat in the schema; the client resolves it to a
|
|
4
|
+
// React component here. Same registry shape as columnRenderers/
|
|
5
|
+
// extensionSectionComponents — String-key in the schema, Component in the
|
|
6
|
+
// client bundle, last-wins merge in createKumikoApp.
|
|
7
|
+
//
|
|
8
|
+
// No entry registered for a format → TextareaContentEditor, so a missing
|
|
9
|
+
// editor is never an empty panel.
|
|
10
|
+
|
|
11
|
+
import { type ComponentType, createContext, type ReactNode, useContext } from "react";
|
|
12
|
+
import { usePrimitives } from "../primitives";
|
|
13
|
+
|
|
14
|
+
export type ContentEditorProps = {
|
|
15
|
+
readonly value: string;
|
|
16
|
+
readonly onChange: (value: string) => void;
|
|
17
|
+
/** Variable names insertable as chips (from the collection's
|
|
18
|
+
* variableSchema). Empty until the variable-chip step wires it up —
|
|
19
|
+
* editors must accept it now, that's the point of fixing the signature
|
|
20
|
+
* here. */
|
|
21
|
+
readonly variables: readonly string[];
|
|
22
|
+
readonly readOnly: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ContentEditorComponent = ComponentType<ContentEditorProps>;
|
|
26
|
+
|
|
27
|
+
/** Fixed DOM id the fallback textarea renders under. Callers that wrap an
|
|
28
|
+
* editor in a `Field` (label + htmlFor) use this as the Field's `id` so the
|
|
29
|
+
* label stays associated — the editor contract has no `id` prop of its own,
|
|
30
|
+
* every registered editor owns its own focusable element's id. */
|
|
31
|
+
export const CONTENT_EDITOR_ELEMENT_ID = "content-editor-textarea";
|
|
32
|
+
|
|
33
|
+
export type ContentEditorsMap = Readonly<Record<string, ContentEditorComponent>>;
|
|
34
|
+
|
|
35
|
+
const ContentEditorsContext = createContext<ContentEditorsMap>({});
|
|
36
|
+
|
|
37
|
+
export type ContentEditorsProviderProps = {
|
|
38
|
+
readonly children: ReactNode;
|
|
39
|
+
readonly value: ContentEditorsMap;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function ContentEditorsProvider({
|
|
43
|
+
children,
|
|
44
|
+
value,
|
|
45
|
+
}: ContentEditorsProviderProps): ReactNode {
|
|
46
|
+
return <ContentEditorsContext.Provider value={value}>{children}</ContentEditorsContext.Provider>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Plain-textarea fallback for a contentFormat with no registered editor.
|
|
50
|
+
* Uses the primitives Input like every other form field, so it renders on
|
|
51
|
+
* every platform without requiring the platform's DOM/native equivalent. */
|
|
52
|
+
export function TextareaContentEditor({
|
|
53
|
+
value,
|
|
54
|
+
onChange,
|
|
55
|
+
readOnly,
|
|
56
|
+
}: ContentEditorProps): ReactNode {
|
|
57
|
+
const { Input } = usePrimitives();
|
|
58
|
+
return (
|
|
59
|
+
<Input
|
|
60
|
+
kind="textarea"
|
|
61
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
62
|
+
name={CONTENT_EDITOR_ELEMENT_ID}
|
|
63
|
+
value={value}
|
|
64
|
+
onChange={onChange}
|
|
65
|
+
disabled={readOnly}
|
|
66
|
+
rows={14}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolves the editor for a contentFormat, falling back to the plain
|
|
72
|
+
* textarea when no clientFeature registered one. `contentFormat`
|
|
73
|
+
* undefined (collection didn't declare one) behaves like "plain". */
|
|
74
|
+
export function useContentEditor(contentFormat?: string): ContentEditorComponent {
|
|
75
|
+
const map = useContext(ContentEditorsContext);
|
|
76
|
+
return map[contentFormat ?? "plain"] ?? TextareaContentEditor;
|
|
77
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Editor-agnostic chip bar for a content editor's declared variables
|
|
2
|
+
// (ContentCollectionDefinition.variableSchema). Renders next to the text
|
|
3
|
+
// surface, never inside it — step 3 (rich/tiptap) reuses this same bar over
|
|
4
|
+
// a different editor, so it must not assume a textarea underneath.
|
|
5
|
+
|
|
6
|
+
import { Fragment, type ReactNode } from "react";
|
|
7
|
+
import { useTranslation } from "../i18n";
|
|
8
|
+
import { usePrimitives } from "../primitives";
|
|
9
|
+
|
|
10
|
+
export type VariableChipsProps = {
|
|
11
|
+
readonly variables: readonly string[];
|
|
12
|
+
readonly onInsert: (name: string) => void;
|
|
13
|
+
readonly disabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// No wrapping element: @cosmicdrift/kumiko-renderer is DOM-free (native impls
|
|
17
|
+
// have no <div>), and layout is a platform concern anyway — the caller
|
|
18
|
+
// (PlainContentEditor et al.) places these buttons in its own container.
|
|
19
|
+
export function VariableChips({ variables, onInsert, disabled }: VariableChipsProps): ReactNode {
|
|
20
|
+
const { Button } = usePrimitives();
|
|
21
|
+
const t = useTranslation();
|
|
22
|
+
return (
|
|
23
|
+
<Fragment>
|
|
24
|
+
{variables.map((name) => (
|
|
25
|
+
<Button
|
|
26
|
+
key={name}
|
|
27
|
+
type="button"
|
|
28
|
+
variant="secondary"
|
|
29
|
+
size="sm"
|
|
30
|
+
disabled={disabled}
|
|
31
|
+
onClick={() => onInsert(name)}
|
|
32
|
+
ariaLabel={t("kumiko.contentEditor.insertVariable", { name })}
|
|
33
|
+
>
|
|
34
|
+
{`{{${name}}}`}
|
|
35
|
+
</Button>
|
|
36
|
+
))}
|
|
37
|
+
</Fragment>
|
|
38
|
+
);
|
|
39
|
+
}
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -85,6 +85,9 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
85
85
|
// Row-Actions — Fehler-Toast wenn ein Action-Write fehlschlägt.
|
|
86
86
|
"kumiko.rowAction.failed": "Aktion fehlgeschlagen",
|
|
87
87
|
|
|
88
|
+
// ContentEditor — Variablen-Chip-Leiste (VariableChips).
|
|
89
|
+
"kumiko.contentEditor.insertVariable": "{name} einfügen",
|
|
90
|
+
|
|
88
91
|
// Config-Cascade — Source-Badges + Cascade-Panel (ConfigCascadeView).
|
|
89
92
|
"kumiko.config.source.user": "Mein Wert",
|
|
90
93
|
"kumiko.config.source.tenant": "Tenant",
|
|
@@ -216,6 +219,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
216
219
|
|
|
217
220
|
"kumiko.rowAction.failed": "Action failed",
|
|
218
221
|
|
|
222
|
+
"kumiko.contentEditor.insertVariable": "Insert {name}",
|
|
223
|
+
|
|
219
224
|
"kumiko.config.source.user": "My value",
|
|
220
225
|
"kumiko.config.source.tenant": "Tenant",
|
|
221
226
|
"kumiko.config.source.system": "System",
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,18 @@ export type {
|
|
|
18
18
|
ColumnRenderersProviderProps,
|
|
19
19
|
} from "./app/column-renderers";
|
|
20
20
|
export { ColumnRenderersProvider, useColumnRenderer } from "./app/column-renderers";
|
|
21
|
+
export type {
|
|
22
|
+
ContentEditorComponent,
|
|
23
|
+
ContentEditorProps,
|
|
24
|
+
ContentEditorsMap,
|
|
25
|
+
ContentEditorsProviderProps,
|
|
26
|
+
} from "./app/content-editors";
|
|
27
|
+
export {
|
|
28
|
+
CONTENT_EDITOR_ELEMENT_ID,
|
|
29
|
+
ContentEditorsProvider,
|
|
30
|
+
TextareaContentEditor,
|
|
31
|
+
useContentEditor,
|
|
32
|
+
} from "./app/content-editors";
|
|
21
33
|
export type { CustomScreensMap, CustomScreensProviderProps } from "./app/custom-screens";
|
|
22
34
|
export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens";
|
|
23
35
|
export type { DashboardBodyProps, DashboardBodyProviderProps } from "./app/dashboard-body";
|
|
@@ -52,6 +64,8 @@ export { KumikoScreen, qualifyNavId, qualifyScreenId } from "./app/kumiko-screen
|
|
|
52
64
|
export type { NavApi, NavProviderProps, NavRoute, NavTarget } from "./app/nav";
|
|
53
65
|
export { formatPath, NavProvider, parsePath, useNav } from "./app/nav";
|
|
54
66
|
export { lastSegment } from "./app/qn";
|
|
67
|
+
export type { VariableChipsProps } from "./app/variable-chips";
|
|
68
|
+
export { VariableChips } from "./app/variable-chips";
|
|
55
69
|
export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error";
|
|
56
70
|
export type { RenderEditProps } from "./components/render-edit";
|
|
57
71
|
export { RenderEdit } from "./components/render-edit";
|