@cosmicdrift/kumiko-renderer 0.194.0 → 0.195.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.194.0",
3
+ "version": "0.195.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.194.0",
19
- "@cosmicdrift/kumiko-headless": "0.194.0",
18
+ "@cosmicdrift/kumiko-framework": "0.195.0",
19
+ "@cosmicdrift/kumiko-headless": "0.195.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -58,7 +58,15 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
58
58
 
59
59
  function Probe({ contentFormat }: { readonly contentFormat?: string }): ReactNode {
60
60
  const Editor = useContentEditor(contentFormat);
61
- return <Editor value="hello" onChange={() => {}} variables={[]} readOnly={false} />;
61
+ return (
62
+ <Editor
63
+ id={CONTENT_EDITOR_ELEMENT_ID}
64
+ value="hello"
65
+ onChange={() => {}}
66
+ variables={[]}
67
+ readOnly={false}
68
+ />
69
+ );
62
70
  }
63
71
 
64
72
  describe("useContentEditor", () => {
@@ -102,7 +110,13 @@ describe("TextareaContentEditor", () => {
102
110
  // non-admin viewing read-only content couldn't select/copy it. readOnly
103
111
  // keeps the field focusable and selectable while blocking edits.
104
112
  render(
105
- <TextareaContentEditor value="draft" onChange={() => {}} variables={[]} readOnly={true} />,
113
+ <TextareaContentEditor
114
+ id={CONTENT_EDITOR_ELEMENT_ID}
115
+ value="draft"
116
+ onChange={() => {}}
117
+ variables={[]}
118
+ readOnly={true}
119
+ />,
106
120
  { wrapper: Wrapper },
107
121
  );
108
122
  const el = screen.getByTestId("ca-textarea") as HTMLTextAreaElement;
@@ -12,6 +12,12 @@ import { type ComponentType, createContext, type ReactNode, useContext } from "r
12
12
  import { usePrimitives } from "../primitives";
13
13
 
14
14
  export type ContentEditorProps = {
15
+ /** DOM id every registered editor must render onto its own focusable
16
+ * root element. Callers that wrap an editor in a `Field` (label +
17
+ * htmlFor) pass a stable id here so the label stays associated with
18
+ * whatever element actually renders — a fixed constant would break as
19
+ * soon as a registered editor swaps in for the textarea fallback. */
20
+ readonly id: string;
15
21
  readonly value: string;
16
22
  readonly onChange: (value: string) => void;
17
23
  /** Variable names insertable as chips (from the collection's
@@ -24,10 +30,10 @@ export type ContentEditorProps = {
24
30
 
25
31
  export type ContentEditorComponent = ComponentType<ContentEditorProps>;
26
32
 
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. */
33
+ /** Default id value for callers that don't generate their own (e.g. a
34
+ * standalone TextareaContentEditor render outside a multi-editor page).
35
+ * Registered editors don't apply this automatically every caller passes
36
+ * its own id via ContentEditorProps.id. */
31
37
  export const CONTENT_EDITOR_ELEMENT_ID = "content-editor-textarea";
32
38
 
33
39
  export type ContentEditorsMap = Readonly<Record<string, ContentEditorComponent>>;
@@ -50,6 +56,7 @@ export function ContentEditorsProvider({
50
56
  * Uses the primitives Input like every other form field, so it renders on
51
57
  * every platform without requiring the platform's DOM/native equivalent. */
52
58
  export function TextareaContentEditor({
59
+ id,
53
60
  value,
54
61
  onChange,
55
62
  readOnly,
@@ -58,7 +65,7 @@ export function TextareaContentEditor({
58
65
  return (
59
66
  <Input
60
67
  kind="textarea"
61
- id={CONTENT_EDITOR_ELEMENT_ID}
68
+ id={id}
62
69
  name={CONTENT_EDITOR_ELEMENT_ID}
63
70
  value={value}
64
71
  onChange={onChange}
@@ -4,7 +4,7 @@
4
4
  // renders formatted HTML and "plain"/"markdown" render as text through the
5
5
  // exact same component, no separate render path per format.
6
6
 
7
- import type { ReactNode } from "react";
7
+ import { type ReactNode, useId } from "react";
8
8
  import { useContentEditor } from "./content-editors";
9
9
 
10
10
  const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}/g;
@@ -36,6 +36,7 @@ export function ContentPreview({
36
36
  contentFormat,
37
37
  }: ContentPreviewProps): ReactNode {
38
38
  const Editor = useContentEditor(contentFormat);
39
+ const id = useId();
39
40
  // "rich" content is HTML (see ContentCollectionDefinition.contentFormat) —
40
41
  // an example value substituted in raw could break the markup (`<`) or
41
42
  // render as an unescaped entity (`&`). "plain"/"markdown" content is text,
@@ -48,6 +49,7 @@ export function ContentPreview({
48
49
  : variables;
49
50
  return (
50
51
  <Editor
52
+ id={id}
51
53
  value={substituteVariables(content, safeVariables)}
52
54
  onChange={noop}
53
55
  variables={[]}
@@ -26,6 +26,7 @@ import type {
26
26
  } from "@cosmicdrift/kumiko-headless";
27
27
  import { fieldLabelKey } from "@cosmicdrift/kumiko-headless";
28
28
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
29
+ import { extractCreatedId } from "../components/reference-create-dialog";
29
30
  import { RenderEdit } from "../components/render-edit";
30
31
  import { RenderList, type ToolbarActionButton } from "../components/render-list";
31
32
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
@@ -461,7 +462,11 @@ function EntityEditCreateBody({
461
462
  (result: SubmitResult<unknown>) => {
462
463
  if (!result.isSuccess) return;
463
464
  if (screen.redirect !== undefined) {
464
- nav.navigate({ screenId: lastSegment(screen.redirect) });
465
+ const entityId = extractCreatedId(result.data);
466
+ nav.navigate({
467
+ screenId: lastSegment(screen.redirect),
468
+ ...(entityId !== undefined && { entityId }),
469
+ });
465
470
  return;
466
471
  }
467
472
  navigateToList();
@@ -23,7 +23,7 @@ function entityWriteCommand(featureName: string, entity: string): string {
23
23
  // The create handler's success payload is `{ kind: "save", id, ... }`
24
24
  // (see event-store-executor-write.ts) but RenderEdit's onSubmit only
25
25
  // types it as `unknown` — narrow defensively instead of casting through it.
26
- function extractCreatedId(data: unknown): string | undefined {
26
+ export function extractCreatedId(data: unknown): string | undefined {
27
27
  if (typeof data !== "object" || data === null) return undefined;
28
28
  const id = (data as Record<string, unknown>)["id"];
29
29
  return typeof id === "string" ? id : undefined;
@@ -25,7 +25,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
25
25
  import type { z } from "zod";
26
26
  import { ExtensionFormRegistryProvider, useExtensionFormHost } from "../app/extension-form-submit";
27
27
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
28
- import { useDispatcher } from "../context/dispatcher-context";
28
+ import { useOptionalDispatcher } from "../context/dispatcher-context";
29
29
  import { useDraftStorage } from "../context/draft-storage-context";
30
30
  import { formatWhen } from "../format-when";
31
31
  import { useForm } from "../hooks/use-form";
@@ -80,6 +80,14 @@ function newDraftPrefix(screenId: string): string {
80
80
  return `${screenId}:new:`;
81
81
  }
82
82
 
83
+ // `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
84
+ // without a polyfill — guard like dispatcher-live.ts's generateRequestId.
85
+ function mintDraftId(): string {
86
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
87
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
88
+ return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
89
+ }
90
+
83
91
  // End-to-end renderer für einen entityEdit screen. Rendert aus-
84
92
  // schließlich über Primitives — kein raw HTML. Ein Native-Renderer
85
93
  // der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
@@ -343,10 +351,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
343
351
  // ohnehin nur in einem mounted Kumiko-App-Tree läuft.
344
352
  const t = useTranslation();
345
353
  const translate = translateProp ?? t;
346
- const dispatcher = useDispatcher();
354
+ const dispatcher = useOptionalDispatcher();
347
355
 
348
356
  const isWizard = screen.layout.mode === "wizard";
349
- const draftEnabled = isWizard && screen.layout.draft === true;
357
+ const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
350
358
  const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
351
359
  const draftStorage = useDraftStorage();
352
360
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
@@ -502,14 +510,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
502
510
  const patchAndScheduleDraftSave = useCallback(
503
511
  (partial: Partial<TValues>) => {
504
512
  controller.setValues(partial);
505
- if (!draftEnabled) return;
513
+ if (!draftEnabled || dispatcher === undefined) return;
506
514
  if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
507
515
  draftSaveTimerRef.current = setTimeout(() => {
508
516
  draftSaveTimerRef.current = null;
509
517
  saveDraftRef.current(currentStepRef.current);
510
518
  }, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
511
519
  },
512
- [controller, draftEnabled],
520
+ [controller, draftEnabled, dispatcher],
513
521
  );
514
522
 
515
523
  // Runs before the onChange effect below (declaration order = React
@@ -556,7 +564,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
556
564
 
557
565
  useEffect(() => {
558
566
  // skip: this screen does not persist a draft.
559
- if (!draftEnabled) return;
567
+ if (!draftEnabled || dispatcher === undefined) return;
560
568
  // skip: create-mode with no draftId yet — nothing to restore, either
561
569
  // the list-fallback effect below finds one or the first step change
562
570
  // mints a fresh one.
@@ -603,7 +611,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
603
611
  // skip: this screen does not persist a draft, this is edit-mode, a
604
612
  // draftId is already known (from storage or an earlier adoption), or
605
613
  // this mount already ran the list lookup once (didListRef).
606
- if (!draftEnabled || !isCreateMode || draftId !== null || didListRef.current) return;
614
+ if (
615
+ !draftEnabled ||
616
+ !isCreateMode ||
617
+ draftId !== null ||
618
+ didListRef.current ||
619
+ dispatcher === undefined
620
+ )
621
+ return;
607
622
  didListRef.current = true;
608
623
  let cancelled = false;
609
624
  void (async () => {
@@ -696,10 +711,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
696
711
  // computed inline instead of read from the memoized `draftKey`.
697
712
  function saveDraft(stepIndex: number): void {
698
713
  // skip: this screen does not persist a draft.
699
- if (!draftEnabled) return;
714
+ if (!draftEnabled || dispatcher === undefined) return;
700
715
  let key = draftKey;
701
716
  if (isCreateMode && draftId === null) {
702
- const mintedId = crypto.randomUUID();
717
+ const mintedId = mintDraftId();
703
718
  mintedDraftIdRef.current = mintedId;
704
719
  draftStorage.setDraftId(screen.id, mintedId);
705
720
  setDraftId(mintedId);
@@ -761,7 +776,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
761
776
 
762
777
  async function discardDraft(): Promise<void> {
763
778
  // skip: this screen does not persist a draft.
764
- if (!draftEnabled) return;
779
+ if (!draftEnabled || dispatcher === undefined) return;
765
780
  // A pending debounced patch-save must not fire after discard — it would
766
781
  // resurrect the draft it just deleted.
767
782
  if (draftSaveTimerRef.current !== null) {