@cosmicdrift/kumiko-renderer 0.181.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.181.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.181.0",
19
- "@cosmicdrift/kumiko-headless": "0.181.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
+ }
@@ -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";