@cosmicdrift/kumiko-renderer-web 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-web",
3
- "version": "0.181.0",
3
+ "version": "0.182.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.181.0",
20
- "@cosmicdrift/kumiko-headless": "0.181.0",
21
- "@cosmicdrift/kumiko-renderer": "0.181.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.182.0",
20
+ "@cosmicdrift/kumiko-headless": "0.182.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.182.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",
@@ -9,10 +9,11 @@ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
9
9
  import type {
10
10
  AppSchema,
11
11
  ColumnRendererProps,
12
+ ContentEditorProps,
12
13
  FeatureSchema,
13
14
  NavApi,
14
15
  } from "@cosmicdrift/kumiko-renderer";
15
- import { createStaticLocaleResolver } from "@cosmicdrift/kumiko-renderer";
16
+ import { createStaticLocaleResolver, useContentEditor } from "@cosmicdrift/kumiko-renderer";
16
17
  import { act, screen, waitFor } from "@testing-library/react";
17
18
  import type { ReactNode } from "react";
18
19
  import type { ClientFeatureDefinition } from "../app/client-plugin";
@@ -315,6 +316,48 @@ describe("createKumikoApp", () => {
315
316
  expect(screen.getByTestId("ca-swatch-field").textContent).toBe("color");
316
317
  });
317
318
 
319
+ test("clientFeatures.contentEditors → createKumikoApp verdrahtet Merge → Provider → useContentEditor end-to-end", async () => {
320
+ // Proves the whole chain: ClientFeatureDefinition.contentEditors →
321
+ // mergeContentEditors → ContentEditorsProvider in the tree →
322
+ // useContentEditor in the consumer. Without the provider mount,
323
+ // useContentEditor would always fall back to the textarea — this test
324
+ // would fail then.
325
+ function RichEditor({ value }: ContentEditorProps): ReactNode {
326
+ return <div data-testid="ca-rich-editor">{value}</div>;
327
+ }
328
+ function EditorProbe(): ReactNode {
329
+ const Editor = useContentEditor("rich");
330
+ return <Editor value="hello" onChange={() => {}} variables={[]} readOnly={false} />;
331
+ }
332
+ const probeSchema: FeatureSchema = {
333
+ featureName: "tasks",
334
+ entities: {},
335
+ screens: [{ id: "editor-probe", type: "custom", renderer: {} }],
336
+ navs: [
337
+ {
338
+ id: "editor-probe",
339
+ label: "tasks:nav.editor-probe",
340
+ screen: "tasks:screen:editor-probe",
341
+ },
342
+ ],
343
+ };
344
+ const clientFeature: ClientFeatureDefinition = {
345
+ name: "tasks",
346
+ components: { "editor-probe": EditorProbe },
347
+ contentEditors: { rich: RichEditor },
348
+ };
349
+
350
+ mountRoot();
351
+ await mountApp({
352
+ schema: probeSchema,
353
+ dispatcher: makeDispatcher(),
354
+ clientFeatures: [clientFeature],
355
+ });
356
+
357
+ expect(await screen.findByTestId("ca-rich-editor")).toBeTruthy();
358
+ expect(screen.getByTestId("ca-rich-editor").textContent).toBe("hello");
359
+ });
360
+
318
361
  test("schema.translations (r.translations, #1059) resolves nav/dashboard labels without any clientFeatures duplication", async () => {
319
362
  // Mirrors cap-counter's real shape: a feature that declares r.translations
320
363
  // for its own nav label but ships NO web/i18n.ts / clientFeatures entry at
@@ -0,0 +1,44 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import type { ContentEditorComponent } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ClientFeatureDefinition } from "../client-plugin";
4
+ import { mergeContentEditors } from "../create-app";
5
+
6
+ const editor = (): ContentEditorComponent => (() => null) as ContentEditorComponent;
7
+
8
+ describe("mergeContentEditors", () => {
9
+ test("merges contentEditors from multiple clientFeatures", () => {
10
+ const plainEditor = editor();
11
+ const richEditor = editor();
12
+ const features: ClientFeatureDefinition[] = [
13
+ { name: "a", contentEditors: { plain: plainEditor } },
14
+ { name: "b", contentEditors: { rich: richEditor } },
15
+ ];
16
+
17
+ const merged = mergeContentEditors(features);
18
+ expect(merged["plain"]).toBe(plainEditor);
19
+ expect(merged["rich"]).toBe(richEditor);
20
+ });
21
+
22
+ test("Key-Kollision → warnt + last-wins gewinnt", () => {
23
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
24
+ const first = editor();
25
+ const second = editor();
26
+ const features: ClientFeatureDefinition[] = [
27
+ { name: "first", contentEditors: { plain: first } },
28
+ { name: "second", contentEditors: { plain: second } },
29
+ ];
30
+
31
+ const merged = mergeContentEditors(features);
32
+ expect(merged["plain"]).toBe(second);
33
+ expect(warnSpy).toHaveBeenCalledWith(
34
+ expect.stringContaining('contentEditor "plain" defined by multiple clientFeatures'),
35
+ );
36
+
37
+ warnSpy.mockRestore();
38
+ });
39
+
40
+ test("clientFeatures ohne contentEditors werden übersprungen", () => {
41
+ const features: ClientFeatureDefinition[] = [{ name: "a" }];
42
+ expect(mergeContentEditors(features)).toEqual({});
43
+ });
44
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type ReactNode, useState } from "react";
3
+ import { fireEvent, render, screen } from "../../__tests__/test-utils";
4
+ import { PlainContentEditor } from "../plain-content-editor";
5
+
6
+ function Controlled({ initial }: { readonly initial: string }): ReactNode {
7
+ const [value, setValue] = useState(initial);
8
+ return (
9
+ <PlainContentEditor value={value} onChange={setValue} variables={["name"]} readOnly={false} />
10
+ );
11
+ }
12
+
13
+ describe("PlainContentEditor", () => {
14
+ test("renders the textarea plus one chip per variable", () => {
15
+ render(
16
+ <PlainContentEditor value="" onChange={() => {}} variables={["name"]} readOnly={false} />,
17
+ );
18
+ expect(screen.getByRole("textbox")).toBeTruthy();
19
+ expect(screen.getByText("{{name}}")).toBeTruthy();
20
+ });
21
+
22
+ test("no variables → no chips", () => {
23
+ render(<PlainContentEditor value="" onChange={() => {}} variables={[]} readOnly={false} />);
24
+ expect(screen.queryAllByRole("button")).toHaveLength(0);
25
+ });
26
+
27
+ test("chip click inserts the placeholder at the caret, not appended", () => {
28
+ render(<Controlled initial="Hello world" />);
29
+ const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
30
+ // Caret between the two spaces (index 6), so the insert must land there —
31
+ // an append-only implementation would put it at the string's end instead.
32
+ textarea.setSelectionRange(6, 6);
33
+ fireEvent.click(screen.getByText("{{name}}"));
34
+ expect(textarea.value).toBe("Hello {{name}} world");
35
+ });
36
+
37
+ test("chip click replaces a selection instead of inserting alongside it", () => {
38
+ render(<Controlled initial="Hello world" />);
39
+ const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
40
+ textarea.setSelectionRange(6, 11); // selects "world"
41
+ fireEvent.click(screen.getByText("{{name}}"));
42
+ expect(textarea.value).toBe("Hello {{name}}");
43
+ });
44
+ });
@@ -12,6 +12,7 @@
12
12
  import type { TargetRef, TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
13
13
  import type {
14
14
  ColumnRendererComponent,
15
+ ContentEditorComponent,
15
16
  ExtensionSectionComponent,
16
17
  QualifiedContentCollection,
17
18
  TranslationsByLocale,
@@ -89,6 +90,15 @@ export type ClientFeatureDefinition = {
89
90
  collections: readonly QualifiedContentCollection[],
90
91
  ) => CollectionNavProviders;
91
92
 
93
+ /** Content-Editor-Components — Map `contentFormat` ("plain" | "rich" | "markdown") →
94
+ * React-Component. `r.contentCollection()` declares which format an
95
+ * editor edits; the collection's editor UI (template-resolver's
96
+ * TextBlockEditor et al.) looks the component up here and falls back to
97
+ * a plain textarea when no clientFeature registered one for that
98
+ * format. Same Last-Wins-Semantik wie columnRenderers. Public component
99
+ * contract: `{ value, onChange, variables, readOnly }`. */
100
+ readonly contentEditors?: Readonly<Record<string, ContentEditorComponent>>;
101
+
92
102
  /** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
93
103
  * TreeNode mit target angeklickt wird, schlägt der EditorPanel das
94
104
  * Component hier nach und rendert es. Komponenten erhalten target
@@ -11,6 +11,8 @@ import {
11
11
  type AppSchema,
12
12
  type ColumnRendererComponent,
13
13
  ColumnRenderersProvider,
14
+ type ContentEditorComponent,
15
+ ContentEditorsProvider,
14
16
  CustomScreensProvider,
15
17
  DashboardBodyProvider,
16
18
  DispatcherProvider,
@@ -216,6 +218,28 @@ export function firstOpenScreenQn(features: readonly FeatureSchema[]): string |
216
218
  return undefined;
217
219
  }
218
220
 
221
+ // Merges the content-editor map — same last-wins semantics as
222
+ // columnRenderers. No entry for a contentFormat → useContentEditor falls
223
+ // back to the textarea on its own, no warning needed.
224
+ export function mergeContentEditors(
225
+ clientFeatures: readonly ClientFeatureDefinition[],
226
+ ): Record<string, ContentEditorComponent> {
227
+ const contentEditors: Record<string, ContentEditorComponent> = {};
228
+ for (const f of clientFeatures) {
229
+ if (f.contentEditors === undefined) continue;
230
+ for (const [key, value] of Object.entries(f.contentEditors)) {
231
+ if (contentEditors[key] !== undefined) {
232
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
233
+ console.warn(
234
+ `[kumiko] contentEditor "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
235
+ );
236
+ }
237
+ contentEditors[key] = value;
238
+ }
239
+ }
240
+ return contentEditors;
241
+ }
242
+
219
243
  export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonly root: Root } {
220
244
  const rootId = options.rootId ?? "root";
221
245
  const container = document.getElementById(rootId);
@@ -320,6 +344,8 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
320
344
  }
321
345
  }
322
346
 
347
+ const contentEditors = mergeContentEditors(clientFeatures);
348
+
323
349
  const { navProviders, navEntities } = buildNavProviderMaps(
324
350
  clientFeatures,
325
351
  app.features.flatMap((f) => f.contentCollections ?? []),
@@ -367,16 +393,18 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
367
393
  <DashboardBodyProvider value={WebDashboardBody}>
368
394
  <CustomScreensProvider value={customScreens}>
369
395
  <ColumnRenderersProvider value={columnRenderers}>
370
- <ExtensionSectionsProvider value={extensionSectionComponents}>
371
- <NavProvidersProvider value={navProviders} entities={navEntities}>
372
- <ResolversProvider resolvers={resolvers}>
373
- <ToastProvider>
374
- <UpdateChecker />
375
- {stackWrappers(providers, stackWrappers(gates, screenNode))}
376
- </ToastProvider>
377
- </ResolversProvider>
378
- </NavProvidersProvider>
379
- </ExtensionSectionsProvider>
396
+ <ContentEditorsProvider value={contentEditors}>
397
+ <ExtensionSectionsProvider value={extensionSectionComponents}>
398
+ <NavProvidersProvider value={navProviders} entities={navEntities}>
399
+ <ResolversProvider resolvers={resolvers}>
400
+ <ToastProvider>
401
+ <UpdateChecker />
402
+ {stackWrappers(providers, stackWrappers(gates, screenNode))}
403
+ </ToastProvider>
404
+ </ResolversProvider>
405
+ </NavProvidersProvider>
406
+ </ExtensionSectionsProvider>
407
+ </ContentEditorsProvider>
380
408
  </ColumnRenderersProvider>
381
409
  </CustomScreensProvider>
382
410
  </DashboardBodyProvider>
@@ -0,0 +1,66 @@
1
+ // @runtime client
2
+ //
3
+ // "plain" contentFormat editor: the primitives textarea (TextareaContentEditor,
4
+ // unchanged — keeps its styling and its Field/label association) plus a
5
+ // variable-chip bar underneath. A chip click inserts `{{name}}` at the caret
6
+ // instead of appending to the end.
7
+ //
8
+ // ponytail: looks the textarea up by CONTENT_EDITOR_ELEMENT_ID rather than a
9
+ // ref threaded through the primitives Input contract — that contract is
10
+ // cross-platform (RN has no DOM node), this file is `@runtime client`-only
11
+ // and CONTENT_EDITOR_ELEMENT_ID exists exactly as this DOM hook. Upgrade to a
12
+ // forwarded ref if a screen ever needs two plain editors mounted at once.
13
+
14
+ import {
15
+ CONTENT_EDITOR_ELEMENT_ID,
16
+ type ContentEditorProps,
17
+ TextareaContentEditor,
18
+ VariableChips,
19
+ } from "@cosmicdrift/kumiko-renderer";
20
+ import type { ReactNode } from "react";
21
+ import { useEffect, useState } from "react";
22
+
23
+ export function PlainContentEditor({
24
+ value,
25
+ onChange,
26
+ variables,
27
+ readOnly,
28
+ }: ContentEditorProps): ReactNode {
29
+ const [caret, setCaret] = useState<number | null>(null);
30
+
31
+ // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on value only — must fire once per committed insert, not on every caret write
32
+ useEffect(() => {
33
+ if (caret === null) return;
34
+ const el = document.getElementById(CONTENT_EDITOR_ELEMENT_ID);
35
+ if (el instanceof HTMLTextAreaElement) {
36
+ el.focus();
37
+ el.setSelectionRange(caret, caret);
38
+ }
39
+ setCaret(null);
40
+ }, [value]);
41
+
42
+ const insertAtCaret = (name: string): void => {
43
+ const el = document.getElementById(CONTENT_EDITOR_ELEMENT_ID);
44
+ const placeholder = `{{${name}}}`;
45
+ if (!(el instanceof HTMLTextAreaElement)) {
46
+ onChange(value + placeholder);
47
+ return;
48
+ }
49
+ const start = el.selectionStart ?? value.length;
50
+ const end = el.selectionEnd ?? value.length;
51
+ setCaret(start + placeholder.length);
52
+ onChange(value.slice(0, start) + placeholder + value.slice(end));
53
+ };
54
+
55
+ return (
56
+ <div>
57
+ <TextareaContentEditor
58
+ value={value}
59
+ onChange={onChange}
60
+ variables={variables}
61
+ readOnly={readOnly}
62
+ />
63
+ <VariableChips variables={variables} onInsert={insertAtCaret} disabled={readOnly} />
64
+ </div>
65
+ );
66
+ }
package/src/index.ts CHANGED
@@ -95,6 +95,7 @@ export type { CreatePublicSurfaceOptions, PublicRoute } from "./app/create-publi
95
95
  export { createPublicSurface } from "./app/create-public-surface";
96
96
  export type { KumikoLinkProps } from "./app/nav";
97
97
  export { KumikoLink, useBrowserNavApi } from "./app/nav";
98
+ export { PlainContentEditor } from "./app/plain-content-editor";
98
99
  export { useResolvers } from "./app/resolvers-context";
99
100
  export type { AppLayoutProps } from "./layout/app-layout";
100
101
  export { AppLayout } from "./layout/app-layout";