@cosmicdrift/kumiko-renderer-web 0.193.1 → 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.
Files changed (49) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/create-app.test.tsx +10 -2
  3. package/src/__tests__/entity-edit-redirect.test.tsx +143 -0
  4. package/src/__tests__/field-hide-label.test.tsx +166 -1
  5. package/src/__tests__/kumiko-screen.test.tsx +59 -0
  6. package/src/__tests__/nav-tree.test.tsx +43 -11
  7. package/src/__tests__/primitives.test.tsx +33 -2
  8. package/src/__tests__/render-edit.test.tsx +397 -9
  9. package/src/__tests__/test-utils.tsx +20 -0
  10. package/src/__tests__/wizard-form-validation.test.tsx +129 -1
  11. package/src/app/__tests__/merge-content-editors.test.ts +2 -2
  12. package/src/app/__tests__/plain-content-editor.test.tsx +28 -3
  13. package/src/app/__tests__/rich-content-editor.test.tsx +6 -2
  14. package/src/app/__tests__/tiptap-editor.test.tsx +102 -7
  15. package/src/app/client-plugin.tsx +3 -3
  16. package/src/app/create-app.tsx +40 -49
  17. package/src/app/plain-content-editor.tsx +9 -8
  18. package/src/app/tiptap-editor.tsx +40 -12
  19. package/src/index.ts +1 -0
  20. package/src/layout/__tests__/workspace-switcher.test.tsx +62 -2
  21. package/src/layout/sidebar-panel.tsx +1 -4
  22. package/src/layout/workspace-switcher.tsx +26 -13
  23. package/src/lib/__tests__/resize-image.test.ts +42 -3
  24. package/src/lib/clamp.ts +3 -0
  25. package/src/lib/resize-image.ts +6 -1
  26. package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
  27. package/src/primitives/__tests__/date-field.test.tsx +37 -0
  28. package/src/primitives/__tests__/date-parse.test.ts +14 -2
  29. package/src/primitives/__tests__/embedded-list-input.test.tsx +77 -2
  30. package/src/primitives/__tests__/file-upload.test.tsx +24 -0
  31. package/src/primitives/date-field.tsx +8 -0
  32. package/src/primitives/date-parse.ts +6 -1
  33. package/src/primitives/dropdown-menu.tsx +23 -0
  34. package/src/primitives/embedded-list-input.tsx +23 -13
  35. package/src/primitives/file-upload.tsx +3 -0
  36. package/src/primitives/index.tsx +114 -120
  37. package/src/primitives/money-input.tsx +4 -7
  38. package/src/primitives/use-narrow-viewport.ts +28 -0
  39. package/src/ui/sheet.tsx +3 -1
  40. package/src/widgets/__tests__/drawer.test.tsx +315 -0
  41. package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
  42. package/src/widgets/__tests__/upload-zone.test.tsx +117 -2
  43. package/src/widgets/__tests__/widgets.test.tsx +25 -0
  44. package/src/widgets/ai-text-field.tsx +3 -1
  45. package/src/widgets/drawer.tsx +62 -31
  46. package/src/widgets/infinity-list.tsx +18 -5
  47. package/src/widgets/progress-bar.tsx +3 -1
  48. package/src/widgets/step-bar.tsx +1 -1
  49. package/src/widgets/upload-zone.tsx +45 -3
@@ -19,7 +19,7 @@ describe("mergeContentEditors", () => {
19
19
  expect(merged["rich"]).toBe(richEditor);
20
20
  });
21
21
 
22
- test("Key-Kollision → warnt + last-wins gewinnt", () => {
22
+ test("key collision → warns + last-wins applies", () => {
23
23
  const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
24
24
  const first = editor();
25
25
  const second = editor();
@@ -37,7 +37,7 @@ describe("mergeContentEditors", () => {
37
37
  warnSpy.mockRestore();
38
38
  });
39
39
 
40
- test("clientFeatures ohne contentEditors werden übersprungen", () => {
40
+ test("clientFeatures without contentEditors are skipped", () => {
41
41
  const features: ClientFeatureDefinition[] = [{ name: "a" }];
42
42
  expect(mergeContentEditors(features)).toEqual({});
43
43
  });
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { CONTENT_EDITOR_ELEMENT_ID } from "@cosmicdrift/kumiko-renderer";
2
3
  import { type ReactNode, useState } from "react";
3
4
  import { fireEvent, render, screen } from "../../__tests__/test-utils";
4
5
  import { PlainContentEditor } from "../plain-content-editor";
@@ -6,21 +7,41 @@ import { PlainContentEditor } from "../plain-content-editor";
6
7
  function Controlled({ initial }: { readonly initial: string }): ReactNode {
7
8
  const [value, setValue] = useState(initial);
8
9
  return (
9
- <PlainContentEditor value={value} onChange={setValue} variables={["name"]} readOnly={false} />
10
+ <PlainContentEditor
11
+ id={CONTENT_EDITOR_ELEMENT_ID}
12
+ value={value}
13
+ onChange={setValue}
14
+ variables={["name"]}
15
+ readOnly={false}
16
+ />
10
17
  );
11
18
  }
12
19
 
13
20
  describe("PlainContentEditor", () => {
14
21
  test("renders the textarea plus one chip per variable", () => {
15
22
  render(
16
- <PlainContentEditor value="" onChange={() => {}} variables={["name"]} readOnly={false} />,
23
+ <PlainContentEditor
24
+ id={CONTENT_EDITOR_ELEMENT_ID}
25
+ value=""
26
+ onChange={() => {}}
27
+ variables={["name"]}
28
+ readOnly={false}
29
+ />,
17
30
  );
18
31
  expect(screen.getByRole("textbox")).toBeTruthy();
19
32
  expect(screen.getByText("{{name}}")).toBeTruthy();
20
33
  });
21
34
 
22
35
  test("no variables → no chips", () => {
23
- render(<PlainContentEditor value="" onChange={() => {}} variables={[]} readOnly={false} />);
36
+ render(
37
+ <PlainContentEditor
38
+ id={CONTENT_EDITOR_ELEMENT_ID}
39
+ value=""
40
+ onChange={() => {}}
41
+ variables={[]}
42
+ readOnly={false}
43
+ />,
44
+ );
24
45
  expect(screen.queryAllByRole("button")).toHaveLength(0);
25
46
  });
26
47
 
@@ -32,6 +53,10 @@ describe("PlainContentEditor", () => {
32
53
  textarea.setSelectionRange(6, 6);
33
54
  fireEvent.click(screen.getByText("{{name}}"));
34
55
  expect(textarea.value).toBe("Hello {{name}} world");
56
+ // Caret-restore effect: focus + selection must land right after the
57
+ // inserted placeholder, not wherever the DOM happened to leave it.
58
+ expect(textarea.selectionStart).toBe(14);
59
+ expect(textarea.selectionEnd).toBe(14);
35
60
  });
36
61
 
37
62
  test("chip click replaces a selection instead of inserting alongside it", () => {
@@ -7,6 +7,7 @@ describe("RichContentEditor", () => {
7
7
  test("falls back to the plain textarea while the tiptap chunk loads, then swaps in the editor", async () => {
8
8
  render(
9
9
  <RichContentEditor
10
+ id={CONTENT_EDITOR_ELEMENT_ID}
10
11
  value="<p>hello</p>"
11
12
  onChange={() => {}}
12
13
  variables={[]}
@@ -15,7 +16,10 @@ describe("RichContentEditor", () => {
15
16
  );
16
17
  // The textarea id proves #1794's "never an empty panel" fallback is the
17
18
  // Suspense boundary — a null fallback would leave nothing to find here.
18
- expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).not.toBeNull();
19
- expect(await screen.findByText("hello")).toBeTruthy();
19
+ // TiptapEditor puts the same id on its contenteditable, so asserting
20
+ // presence alone wouldn't distinguish the two; assert the tag instead.
21
+ expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)?.tagName).toBe("TEXTAREA");
22
+ const editable = await screen.findByText("hello");
23
+ expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
20
24
  });
21
25
  });
@@ -6,7 +6,33 @@ import TiptapEditor from "../tiptap-editor";
6
6
 
7
7
  function Controlled({ initial }: { readonly initial: string }): ReactNode {
8
8
  const [value, setValue] = useState(initial);
9
- return <TiptapEditor value={value} onChange={setValue} variables={["name"]} readOnly={false} />;
9
+ return (
10
+ <TiptapEditor
11
+ id={CONTENT_EDITOR_ELEMENT_ID}
12
+ value={value}
13
+ onChange={setValue}
14
+ variables={["name"]}
15
+ readOnly={false}
16
+ />
17
+ );
18
+ }
19
+
20
+ function ReadOnlyToggle({ initial }: { readonly initial: string }): ReactNode {
21
+ const [readOnly, setReadOnly] = useState(false);
22
+ return (
23
+ <div>
24
+ <button type="button" onClick={() => setReadOnly((r) => !r)}>
25
+ toggle readOnly
26
+ </button>
27
+ <TiptapEditor
28
+ id={CONTENT_EDITOR_ELEMENT_ID}
29
+ value={initial}
30
+ onChange={() => {}}
31
+ variables={[]}
32
+ readOnly={readOnly}
33
+ />
34
+ </div>
35
+ );
10
36
  }
11
37
 
12
38
  // Mirrors the real caller: TextBlockEditor mounts with value="" and only
@@ -20,7 +46,13 @@ function LateValue({ loaded }: { readonly loaded: string }): ReactNode {
20
46
  <button type="button" onClick={() => setValue(loaded)}>
21
47
  load
22
48
  </button>
23
- <TiptapEditor value={value} onChange={setValue} variables={[]} readOnly={false} />
49
+ <TiptapEditor
50
+ id={CONTENT_EDITOR_ELEMENT_ID}
51
+ value={value}
52
+ onChange={setValue}
53
+ variables={[]}
54
+ readOnly={false}
55
+ />
24
56
  </div>
25
57
  );
26
58
  }
@@ -28,22 +60,40 @@ function LateValue({ loaded }: { readonly loaded: string }): ReactNode {
28
60
  describe("TiptapEditor — jsdom smoke", () => {
29
61
  test("mounts a contenteditable surface for the given HTML", async () => {
30
62
  render(
31
- <TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
63
+ <TiptapEditor
64
+ id={CONTENT_EDITOR_ELEMENT_ID}
65
+ value="<p>hello</p>"
66
+ onChange={() => {}}
67
+ variables={[]}
68
+ readOnly={false}
69
+ />,
32
70
  );
33
71
  const editable = await screen.findByText("hello");
34
72
  expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
35
73
  });
36
74
 
37
- test("the contenteditable surface carries CONTENT_EDITOR_ELEMENT_ID so the wrapping Field's label stays associated", async () => {
75
+ test("the editor's id prop lands on the contenteditable surface so the wrapping Field's label stays associated", async () => {
38
76
  render(
39
- <TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
77
+ <TiptapEditor
78
+ id={CONTENT_EDITOR_ELEMENT_ID}
79
+ value="<p>hello</p>"
80
+ onChange={() => {}}
81
+ variables={[]}
82
+ readOnly={false}
83
+ />,
40
84
  );
41
85
  expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).not.toBeNull();
42
86
  });
43
87
 
44
88
  test("no variables → no chip bar", async () => {
45
89
  render(
46
- <TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
90
+ <TiptapEditor
91
+ id={CONTENT_EDITOR_ELEMENT_ID}
92
+ value="<p>hello</p>"
93
+ onChange={() => {}}
94
+ variables={[]}
95
+ readOnly={false}
96
+ />,
47
97
  );
48
98
  await screen.findByText("hello");
49
99
  expect(screen.queryAllByRole("button", { name: "Bold" })).toHaveLength(1);
@@ -52,7 +102,13 @@ describe("TiptapEditor — jsdom smoke", () => {
52
102
 
53
103
  test("every toolbar action resolves by its accessible name", async () => {
54
104
  render(
55
- <TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
105
+ <TiptapEditor
106
+ id={CONTENT_EDITOR_ELEMENT_ID}
107
+ value="<p>hello</p>"
108
+ onChange={() => {}}
109
+ variables={[]}
110
+ readOnly={false}
111
+ />,
56
112
  );
57
113
  await screen.findByText("hello");
58
114
  for (const name of [
@@ -91,4 +147,43 @@ describe("TiptapEditor — jsdom smoke", () => {
91
147
  fireEvent.click(screen.getByText("{{name}}"));
92
148
  expect(await screen.findByText("{{name}}", { selector: "p" })).toBeTruthy();
93
149
  });
150
+
151
+ test("toggling readOnly flips the surface's contenteditable and disables the toolbar", async () => {
152
+ // @tiptap/react re-applies `editable` from options on every options
153
+ // refresh, overriding what `editable: !readOnly` set at creation — so
154
+ // this only stays correct across a readOnly prop change via the
155
+ // `editor.setEditable` effect. A test that only mounts with a fixed
156
+ // readOnly value can't catch that effect being removed.
157
+ render(<ReadOnlyToggle initial="<p>hello</p>" />);
158
+ const editable = await screen.findByText("hello");
159
+ expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
160
+ expect((screen.getByRole("button", { name: "Bold" }) as HTMLButtonElement).disabled).toBe(
161
+ false,
162
+ );
163
+
164
+ fireEvent.click(screen.getByText("toggle readOnly"));
165
+
166
+ expect(editable.closest('[contenteditable="false"]')).not.toBeNull();
167
+ expect((screen.getByRole("button", { name: "Bold" }) as HTMLButtonElement).disabled).toBe(true);
168
+ });
169
+
170
+ // Documents the ponytail-noted ceiling on useEditor: markup StarterKit's
171
+ // schema doesn't model (tables, raw divs/spans, style attrs) is dropped on
172
+ // first parse, invisible until the first onChange fires. Not a bug to fix
173
+ // here — a fixture for whoever hits the ceiling next.
174
+ test("markup outside StarterKit's schema (a <table>) is dropped on the first onChange", async () => {
175
+ const values: string[] = [];
176
+ render(
177
+ <TiptapEditor
178
+ id={CONTENT_EDITOR_ELEMENT_ID}
179
+ value="<table><tbody><tr><td>cell</td></tr></tbody></table><p>hello</p>"
180
+ onChange={(html) => values.push(html)}
181
+ variables={[]}
182
+ readOnly={false}
183
+ />,
184
+ );
185
+ const editable = (await screen.findByText("hello")).closest('[contenteditable="true"]');
186
+ fireEvent.click(screen.getByRole("button", { name: "Bold" }));
187
+ expect(editable?.innerHTML).not.toContain("<table");
188
+ });
94
189
  });
@@ -90,12 +90,12 @@ export type ClientFeatureDefinition = {
90
90
  collections: readonly QualifiedContentCollection[],
91
91
  ) => CollectionNavProviders;
92
92
 
93
- /** Content-Editor-Components — Map `contentFormat` ("plain" | "rich" | "markdown") →
94
- * React-Component. `r.contentCollection()` declares which format an
93
+ /** Content editor components — map `contentFormat` ("plain" | "rich" | "markdown") →
94
+ * React component. `r.contentCollection()` declares which format an
95
95
  * editor edits; the collection's editor UI (template-resolver's
96
96
  * TextBlockEditor et al.) looks the component up here and falls back to
97
97
  * a plain textarea when no clientFeature registered one for that
98
- * format. Same Last-Wins-Semantik wie columnRenderers. Public component
98
+ * format. Same last-wins semantics as columnRenderers. Public component
99
99
  * contract: `{ value, onChange, variables, readOnly }`. */
100
100
  readonly contentEditors?: Readonly<Record<string, ContentEditorComponent>>;
101
101
 
@@ -9,7 +9,6 @@ import type {
9
9
  import {
10
10
  AppFeaturesProvider,
11
11
  type AppSchema,
12
- type ColumnRendererComponent,
13
12
  ColumnRenderersProvider,
14
13
  type ContentEditorComponent,
15
14
  ContentEditorsProvider,
@@ -18,7 +17,6 @@ import {
18
17
  DispatcherProvider,
19
18
  type DraftStorage,
20
19
  DraftStorageProvider,
21
- type ExtensionSectionComponent,
22
20
  ExtensionSectionsProvider,
23
21
  type FeatureSchema,
24
22
  KumikoScreen,
@@ -99,7 +97,7 @@ export function buildNavProviderMaps(
99
97
  for (const [navId, provider] of Object.entries(f.navProviders ?? {})) {
100
98
  const qn = qualifyNavProviderKey(f.name, navId);
101
99
  if (navProviders.has(qn) && !derivedQns.delete(qn)) {
102
- // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
100
+ // biome-ignore lint/suspicious/noConsole: dev warning for schema conflicts
103
101
  console.warn(
104
102
  `[kumiko] navProvider for "${qn}" defined by multiple clientFeatures — last wins.`,
105
103
  );
@@ -228,26 +226,39 @@ export function firstOpenScreenQn(features: readonly FeatureSchema[]): string |
228
226
  return undefined;
229
227
  }
230
228
 
231
- // Merges the content-editor map — same last-wins semantics as
232
- // columnRenderers. No entry for a contentFormat → useContentEditor falls
233
- // back to the textarea on its own, no warning needed.
234
- export function mergeContentEditors(
229
+ // Last-wins merge for a clientFeature-contributed map (contentEditors,
230
+ // columnRenderers, extensionSectionComponents, ...): later features in
231
+ // `clientFeatures` override earlier ones on key collision, logging once
232
+ // per collision so an accidental override isn't silent.
233
+ function mergeByKey<T>(
235
234
  clientFeatures: readonly ClientFeatureDefinition[],
236
- ): Record<string, ContentEditorComponent> {
237
- const contentEditors: Record<string, ContentEditorComponent> = {};
235
+ pick: (f: ClientFeatureDefinition) => Record<string, T> | undefined,
236
+ label: string,
237
+ ): Record<string, T> {
238
+ const merged: Record<string, T> = {};
238
239
  for (const f of clientFeatures) {
239
- if (f.contentEditors === undefined) continue;
240
- for (const [key, value] of Object.entries(f.contentEditors)) {
241
- if (contentEditors[key] !== undefined) {
242
- // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
240
+ const entries = pick(f);
241
+ if (entries === undefined) continue;
242
+ for (const [key, value] of Object.entries(entries)) {
243
+ if (merged[key] !== undefined) {
244
+ // biome-ignore lint/suspicious/noConsole: dev warning for schema conflicts
243
245
  console.warn(
244
- `[kumiko] contentEditor "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
246
+ `[kumiko] ${label} "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
245
247
  );
246
248
  }
247
- contentEditors[key] = value;
249
+ merged[key] = value;
248
250
  }
249
251
  }
250
- return contentEditors;
252
+ return merged;
253
+ }
254
+
255
+ // Merges the content-editor map — same last-wins semantics as
256
+ // columnRenderers. No entry for a contentFormat → useContentEditor falls
257
+ // back to the textarea on its own, no warning needed.
258
+ export function mergeContentEditors(
259
+ clientFeatures: readonly ClientFeatureDefinition[],
260
+ ): Record<string, ContentEditorComponent> {
261
+ return mergeByKey(clientFeatures, (f) => f.contentEditors, "contentEditor");
251
262
  }
252
263
 
253
264
  export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonly root: Root } {
@@ -321,39 +332,19 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
321
332
  for (const f of clientFeatures) {
322
333
  if (f.components !== undefined) Object.assign(customScreens, f.components);
323
334
  }
324
- // Column-Renderer-Map mergen — gleiche Last-Wins-Semantik wie bei
325
- // customScreens. Doppelte Keys über Features sind selten gewollt;
326
- // wir warnen einmalig pro Kollision damit das nicht stillschweigend
327
- // den Renderer einer Library überschreibt.
328
- const columnRenderers: Record<string, ColumnRendererComponent> = {};
329
- for (const f of clientFeatures) {
330
- if (f.columnRenderers === undefined) continue;
331
- for (const [key, value] of Object.entries(f.columnRenderers)) {
332
- if (columnRenderers[key] !== undefined) {
333
- // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
334
- console.warn(
335
- `[kumiko] columnRenderer "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
336
- );
337
- }
338
- columnRenderers[key] = value;
339
- }
340
- }
341
- // Extension-Section-Components — same Last-Wins + Warn-Semantik wie
342
- // columnRenderers. Mountet sich am ExtensionSectionsProvider; RenderEdit
343
- // löst die Component aus dem `__component`-Marker der section.
344
- const extensionSectionComponents: Record<string, ExtensionSectionComponent> = {};
345
- for (const f of clientFeatures) {
346
- if (f.extensionSectionComponents === undefined) continue;
347
- for (const [key, value] of Object.entries(f.extensionSectionComponents)) {
348
- if (extensionSectionComponents[key] !== undefined) {
349
- // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
350
- console.warn(
351
- `[kumiko] extensionSectionComponent "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
352
- );
353
- }
354
- extensionSectionComponents[key] = value;
355
- }
356
- }
335
+ // Column-renderer map, same last-wins semantics as customScreens —
336
+ // duplicate keys across features are rarely intentional, so a
337
+ // collision logs once instead of silently overriding a library's
338
+ // renderer.
339
+ const columnRenderers = mergeByKey(clientFeatures, (f) => f.columnRenderers, "columnRenderer");
340
+ // Extension-section components — same last-wins + warn semantics as
341
+ // columnRenderers. Mounted on ExtensionSectionsProvider; RenderEdit
342
+ // resolves the component from the section's `__component` marker.
343
+ const extensionSectionComponents = mergeByKey(
344
+ clientFeatures,
345
+ (f) => f.extensionSectionComponents,
346
+ "extensionSectionComponent",
347
+ );
357
348
 
358
349
  const contentEditors = mergeContentEditors(clientFeatures);
359
350
 
@@ -5,14 +5,13 @@
5
5
  // variable-chip bar underneath. A chip click inserts `{{name}}` at the caret
6
6
  // instead of appending to the end.
7
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.
8
+ // ponytail: looks the textarea up via document.getElementById(id) rather
9
+ // than a ref threaded through the primitives Input contract — that contract
10
+ // is cross-platform (RN has no DOM node), this file is `@runtime client`-
11
+ // only. `id` is per-instance (caller passes a stable unique id via
12
+ // ContentEditorProps.id), so two plain editors mounted at once don't collide.
13
13
 
14
14
  import {
15
- CONTENT_EDITOR_ELEMENT_ID,
16
15
  type ContentEditorProps,
17
16
  TextareaContentEditor,
18
17
  VariableChips,
@@ -21,6 +20,7 @@ import type { ReactNode } from "react";
21
20
  import { useEffect, useState } from "react";
22
21
 
23
22
  export function PlainContentEditor({
23
+ id,
24
24
  value,
25
25
  onChange,
26
26
  variables,
@@ -31,7 +31,7 @@ export function PlainContentEditor({
31
31
  // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on value only — must fire once per committed insert, not on every caret write
32
32
  useEffect(() => {
33
33
  if (caret === null) return;
34
- const el = document.getElementById(CONTENT_EDITOR_ELEMENT_ID);
34
+ const el = document.getElementById(id);
35
35
  if (el instanceof HTMLTextAreaElement) {
36
36
  el.focus();
37
37
  el.setSelectionRange(caret, caret);
@@ -40,7 +40,7 @@ export function PlainContentEditor({
40
40
  }, [value]);
41
41
 
42
42
  const insertAtCaret = (name: string): void => {
43
- const el = document.getElementById(CONTENT_EDITOR_ELEMENT_ID);
43
+ const el = document.getElementById(id);
44
44
  const placeholder = `{{${name}}}`;
45
45
  if (!(el instanceof HTMLTextAreaElement)) {
46
46
  onChange(value + placeholder);
@@ -55,6 +55,7 @@ export function PlainContentEditor({
55
55
  return (
56
56
  <div>
57
57
  <TextareaContentEditor
58
+ id={id}
58
59
  value={value}
59
60
  onChange={onChange}
60
61
  variables={variables}
@@ -7,14 +7,13 @@
7
7
  // link button, so no URL-prompt UI to build or test.
8
8
 
9
9
  import {
10
- CONTENT_EDITOR_ELEMENT_ID,
11
10
  type ContentEditorProps,
12
11
  TextareaContentEditor,
13
12
  usePrimitives,
14
13
  useTranslation,
15
14
  VariableChips,
16
15
  } from "@cosmicdrift/kumiko-renderer";
17
- import { EditorContent, useEditor } from "@tiptap/react";
16
+ import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
18
17
  import StarterKit from "@tiptap/starter-kit";
19
18
  import {
20
19
  Bold as BoldIcon,
@@ -62,12 +61,19 @@ function Toolbar({
62
61
  }
63
62
 
64
63
  export default function TiptapEditor({
64
+ id,
65
65
  value,
66
66
  onChange,
67
67
  variables,
68
68
  readOnly,
69
69
  }: ContentEditorProps): ReactNode {
70
70
  const t = useTranslation();
71
+ // ponytail: rich = StarterKit's schema (bold/italic/lists/headings/links).
72
+ // Anything the schema doesn't model — <table>, <div>/<span>, style attrs,
73
+ // <img>, classes — is dropped on first parse (setContent below, and the
74
+ // initial `content: value` here). Upgrade if that ceiling is hit: add the
75
+ // matching tiptap extensions, or a raw-HTML mode for markup StarterKit
76
+ // can't represent.
71
77
  const editor = useEditor({
72
78
  extensions: [StarterKit],
73
79
  content: value,
@@ -76,13 +82,34 @@ export default function TiptapEditor({
76
82
  // this component before committing it; immediate render would create
77
83
  // an editor instance during that throwaway pass.
78
84
  immediatelyRender: false,
79
- // Same id the textarea fallback uses — the Field wrapping the editor
80
- // (TextBlockEditor) associates its label via this id; the editor
81
- // contract has no `id` prop of its own, see content-editors.tsx.
82
- editorProps: { attributes: { id: CONTENT_EDITOR_ELEMENT_ID } },
85
+ // Caller-supplied id — the Field wrapping the editor (TextBlockEditor)
86
+ // associates its label via this id, see ContentEditorProps.id in
87
+ // content-editors.tsx.
88
+ editorProps: { attributes: { id } },
83
89
  onUpdate: ({ editor: e }) => onChange(e.getHTML()),
84
90
  });
85
91
 
92
+ // `useEditor` alone leaves `editor.isActive(...)` reads in the render body
93
+ // frozen on the last prop-driven render — @tiptap/react v3 defaults
94
+ // `shouldRerenderOnTransaction` to false, so a selection-only change (e.g.
95
+ // moving the cursor into bold text without typing) never triggers a
96
+ // re-render on its own. `useEditorState` subscribes to transactions itself
97
+ // and only re-renders when the selected slice of state actually changes.
98
+ const activeState = useEditorState({
99
+ editor,
100
+ selector: ({ editor: e }) =>
101
+ e === null
102
+ ? null
103
+ : {
104
+ bold: e.isActive("bold"),
105
+ italic: e.isActive("italic"),
106
+ heading1: e.isActive("heading", { level: 1 }),
107
+ heading2: e.isActive("heading", { level: 2 }),
108
+ bulletList: e.isActive("bulletList"),
109
+ orderedList: e.isActive("orderedList"),
110
+ },
111
+ });
112
+
86
113
  // TextBlockEditor loads the entry's content asynchronously (by-slug query
87
114
  // resolves after mount), so `value` arrives after useEditor already read
88
115
  // its initial (empty) content once. Sync it in — guarded against
@@ -100,6 +127,7 @@ export default function TiptapEditor({
100
127
  if (!editor)
101
128
  return (
102
129
  <TextareaContentEditor
130
+ id={id}
103
131
  value={value}
104
132
  onChange={onChange}
105
133
  variables={variables}
@@ -111,37 +139,37 @@ export default function TiptapEditor({
111
139
  {
112
140
  label: t("kumiko.contentEditor.bold"),
113
141
  icon: BoldIcon,
114
- isActive: editor.isActive("bold"),
142
+ isActive: activeState?.bold ?? false,
115
143
  onClick: () => editor.chain().focus().toggleBold().run(),
116
144
  },
117
145
  {
118
146
  label: t("kumiko.contentEditor.italic"),
119
147
  icon: ItalicIcon,
120
- isActive: editor.isActive("italic"),
148
+ isActive: activeState?.italic ?? false,
121
149
  onClick: () => editor.chain().focus().toggleItalic().run(),
122
150
  },
123
151
  {
124
152
  label: t("kumiko.contentEditor.heading1"),
125
153
  icon: Heading1,
126
- isActive: editor.isActive("heading", { level: 1 }),
154
+ isActive: activeState?.heading1 ?? false,
127
155
  onClick: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
128
156
  },
129
157
  {
130
158
  label: t("kumiko.contentEditor.heading2"),
131
159
  icon: Heading2,
132
- isActive: editor.isActive("heading", { level: 2 }),
160
+ isActive: activeState?.heading2 ?? false,
133
161
  onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
134
162
  },
135
163
  {
136
164
  label: t("kumiko.contentEditor.bulletList"),
137
165
  icon: List,
138
- isActive: editor.isActive("bulletList"),
166
+ isActive: activeState?.bulletList ?? false,
139
167
  onClick: () => editor.chain().focus().toggleBulletList().run(),
140
168
  },
141
169
  {
142
170
  label: t("kumiko.contentEditor.orderedList"),
143
171
  icon: ListOrdered,
144
- isActive: editor.isActive("orderedList"),
172
+ isActive: activeState?.orderedList ?? false,
145
173
  onClick: () => editor.chain().focus().toggleOrderedList().run(),
146
174
  },
147
175
  ];
package/src/index.ts CHANGED
@@ -153,6 +153,7 @@ export {
153
153
  DropdownMenuTrigger,
154
154
  } from "./primitives/dropdown-menu";
155
155
  export { PageSection, Stack } from "./primitives/layout";
156
+ export { formatMoney } from "./primitives/money-input";
156
157
  export type { ToastOptions, ToastProviderProps, ToastVariant } from "./primitives/toast";
157
158
  export { ToastProvider, useToast } from "./primitives/toast";
158
159
  export type { CreateEventSourceLiveEventsOptions } from "./sse/live-events";