@multiplatform.one/rich-text 7.4.1 → 7.6.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 (56) hide show
  1. package/dist/cjs/RichTextEditor/index.cjs +115 -50
  2. package/dist/cjs/RichTextEditor/index.native.js +119 -53
  3. package/dist/cjs/RichTextEditor/index.native.js.map +1 -1
  4. package/dist/cjs/toolbar/HeadingPicker.cjs +0 -7
  5. package/dist/cjs/toolbar/HeadingPicker.native.js +17 -13
  6. package/dist/cjs/toolbar/HeadingPicker.native.js.map +1 -1
  7. package/dist/cjs/toolbar/ListPicker.cjs +0 -7
  8. package/dist/cjs/toolbar/ListPicker.native.js +11 -12
  9. package/dist/cjs/toolbar/ListPicker.native.js.map +1 -1
  10. package/dist/cjs/toolbar/MoreDropdown.cjs +0 -7
  11. package/dist/cjs/toolbar/MoreDropdown.native.js +11 -12
  12. package/dist/cjs/toolbar/MoreDropdown.native.js.map +1 -1
  13. package/dist/esm/RichTextEditor/index.mjs +117 -53
  14. package/dist/esm/RichTextEditor/index.mjs.map +1 -1
  15. package/dist/esm/RichTextEditor/index.native.js +121 -56
  16. package/dist/esm/RichTextEditor/index.native.js.map +1 -1
  17. package/dist/esm/toolbar/HeadingPicker.mjs +0 -7
  18. package/dist/esm/toolbar/HeadingPicker.mjs.map +1 -1
  19. package/dist/esm/toolbar/HeadingPicker.native.js +17 -13
  20. package/dist/esm/toolbar/HeadingPicker.native.js.map +1 -1
  21. package/dist/esm/toolbar/ListPicker.mjs +0 -7
  22. package/dist/esm/toolbar/ListPicker.mjs.map +1 -1
  23. package/dist/esm/toolbar/ListPicker.native.js +11 -12
  24. package/dist/esm/toolbar/ListPicker.native.js.map +1 -1
  25. package/dist/esm/toolbar/MoreDropdown.mjs +0 -7
  26. package/dist/esm/toolbar/MoreDropdown.mjs.map +1 -1
  27. package/dist/esm/toolbar/MoreDropdown.native.js +11 -12
  28. package/dist/esm/toolbar/MoreDropdown.native.js.map +1 -1
  29. package/dist/jsx/RichTextEditor/index.mjs +117 -53
  30. package/dist/jsx/RichTextEditor/index.mjs.map +1 -1
  31. package/dist/jsx/RichTextEditor/index.native.js +119 -53
  32. package/dist/jsx/RichTextEditor/index.native.js.map +1 -1
  33. package/dist/jsx/toolbar/HeadingPicker.mjs +0 -7
  34. package/dist/jsx/toolbar/HeadingPicker.mjs.map +1 -1
  35. package/dist/jsx/toolbar/HeadingPicker.native.js +17 -13
  36. package/dist/jsx/toolbar/HeadingPicker.native.js.map +1 -1
  37. package/dist/jsx/toolbar/ListPicker.mjs +0 -7
  38. package/dist/jsx/toolbar/ListPicker.mjs.map +1 -1
  39. package/dist/jsx/toolbar/ListPicker.native.js +11 -12
  40. package/dist/jsx/toolbar/ListPicker.native.js.map +1 -1
  41. package/dist/jsx/toolbar/MoreDropdown.mjs +0 -7
  42. package/dist/jsx/toolbar/MoreDropdown.mjs.map +1 -1
  43. package/dist/jsx/toolbar/MoreDropdown.native.js +11 -12
  44. package/dist/jsx/toolbar/MoreDropdown.native.js.map +1 -1
  45. package/package.json +8 -8
  46. package/src/RichEditor.stories.tsx +36 -1
  47. package/src/RichTextEditor/index.tsx +134 -50
  48. package/src/RichTextEditor/richTextEditor.spec.tsx +141 -147
  49. package/src/toolbar/HeadingPicker.tsx +15 -14
  50. package/src/toolbar/ListPicker.tsx +9 -13
  51. package/src/toolbar/MoreDropdown.tsx +9 -13
  52. package/types/RichTextEditor/index.d.ts +6 -0
  53. package/types/RichTextEditor/index.d.ts.map +1 -1
  54. package/types/toolbar/HeadingPicker.d.ts.map +1 -1
  55. package/types/toolbar/ListPicker.d.ts.map +1 -1
  56. package/types/toolbar/MoreDropdown.d.ts.map +1 -1
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Stories: Main, WithInitialContent, WithoutToolbar, BubbleToolbarOnly,
5
5
  * MinimalToolbar, ReadOnly, WithError, WithMaxHeight, Interactive,
6
- * FormIntegrated, Skeleton
6
+ * FormIntegrated, Skeleton, InTable
7
7
  */
8
8
  import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
9
+ import { DataTable, type DataTableColumn } from "@multiplatform.one/table";
9
10
  import { useForm } from "@tanstack/react-form";
10
11
  import { useRef, useState } from "react";
11
12
  // Catalog Button (LC-56 story honesty): stories compose catalog controls, never raw tamagui ones.
@@ -518,3 +519,37 @@ export const WithUploadHooks: Story = {
518
519
  ),
519
520
  },
520
521
  };
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // InTable (colocated DataTable cell — SB-KS-02 sized parent, no pagination)
525
+ // ---------------------------------------------------------------------------
526
+
527
+ type InTableRow = { id: string; label: string; value: string };
528
+
529
+ export const InTable: Story = {
530
+ name: "In table",
531
+ render: () => {
532
+ const [data, setData] = useState<InTableRow[]>([
533
+ { id: "1", label: "Alpha", value: "<p>Hello <strong>world</strong></p>" },
534
+ { id: "2", label: "Beta", value: "" },
535
+ {
536
+ id: "3",
537
+ label: "Gamma",
538
+ value: "<ul><li>Draft</li><li>Review</li><li>Ship</li></ul>",
539
+ },
540
+ ]);
541
+ const columns: DataTableColumn<InTableRow>[] = [
542
+ { accessorKey: "label", header: "Row" },
543
+ {
544
+ accessorKey: "value",
545
+ header: "Rich text",
546
+ field: "richtexteditor",
547
+ },
548
+ ];
549
+ return (
550
+ <YStack width="100%" height={360}>
551
+ <DataTable data={data} columns={columns} enablePagination={false} onDataChange={setData} />
552
+ </YStack>
553
+ );
554
+ },
555
+ };
@@ -10,10 +10,11 @@
10
10
  * Uses custom empty check for '<p></p>' which is Tiptap's empty state.
11
11
  */
12
12
  import type { DeepKeys, DeepValue } from "@tanstack/form-core";
13
+ import type { RefObject } from "react";
13
14
  import type React from "react";
14
- import { useCallback, useRef, useState } from "react";
15
+ import { useCallback, useEffect, useRef, useState } from "react";
15
16
  import type { LabelProps, SizeTokens } from "tamagui";
16
- import { YStack } from "tamagui";
17
+ import { isWeb } from "tamagui";
17
18
 
18
19
  import {
19
20
  Field,
@@ -44,7 +45,83 @@ import { t } from "../shared/t";
44
45
  * Rich text empty check: treats null, undefined, empty string, and
45
46
  * Tiptap's default empty paragraph as empty values.
46
47
  */
47
- const richTextIsEmpty = (v: unknown): boolean => !v || v === "<p></p>" || v === "";
48
+ export function richTextIsEmpty(v: unknown): boolean {
49
+ return !v || v === "<p></p>" || v === "";
50
+ }
51
+
52
+ function applyExternalContent(
53
+ editor: RichEditorRef | null,
54
+ next: string,
55
+ capabilities?: EditorCapabilities,
56
+ ) {
57
+ if (!editor) return;
58
+ if (capabilities === "markdown") {
59
+ editor.setMarkdown(next);
60
+ return;
61
+ }
62
+ editor.setHTML(next);
63
+ }
64
+
65
+ /**
66
+ * Mirrors a host value (controlled `value` or TanStack field state) into the
67
+ * editor after mount. Display-only: never emits onChange (LC-14).
68
+ */
69
+ function RichTextExternalValueSync({
70
+ value,
71
+ editorRef,
72
+ capabilities,
73
+ }: {
74
+ value: string;
75
+ editorRef: RefObject<RichEditorRef | null>;
76
+ capabilities?: EditorCapabilities;
77
+ }) {
78
+ const isFirst = useRef(true);
79
+ const lastPushed = useRef(value);
80
+ useEffect(() => {
81
+ if (isFirst.current) {
82
+ isFirst.current = false;
83
+ lastPushed.current = value;
84
+ return;
85
+ }
86
+ if (value === lastPushed.current) return;
87
+ lastPushed.current = value;
88
+ applyExternalContent(editorRef.current, value, capabilities);
89
+ }, [value, editorRef, capabilities]);
90
+ return null;
91
+ }
92
+
93
+ function EditorFocusTarget({
94
+ id,
95
+ editorRef,
96
+ }: {
97
+ id: string;
98
+ editorRef: RefObject<RichEditorRef | null>;
99
+ }) {
100
+ if (!isWeb) return null;
101
+ return (
102
+ <textarea
103
+ id={id}
104
+ tabIndex={-1}
105
+ aria-hidden
106
+ readOnly
107
+ value=""
108
+ onFocus={(event) => {
109
+ event.currentTarget.blur();
110
+ editorRef.current?.focus();
111
+ }}
112
+ style={{
113
+ position: "absolute",
114
+ width: 1,
115
+ height: 1,
116
+ padding: 0,
117
+ margin: 0,
118
+ border: 0,
119
+ opacity: 0,
120
+ overflow: "hidden",
121
+ }}
122
+ />
123
+ );
124
+ }
48
125
 
49
126
  // ---------------------------------------------------------------------------
50
127
  // Types
@@ -81,6 +158,7 @@ export type RichTextEditorProps<
81
158
  error?: string | boolean;
82
159
  required?: boolean;
83
160
  size?: SizeTokens;
161
+ id?: string;
84
162
  /** Show the formatting toolbar (default: true) */
85
163
  showToolbar?: boolean;
86
164
  /** Use compact sizing for toolbar (default: false) */
@@ -151,6 +229,7 @@ export function RichTextEditor<
151
229
  helperText,
152
230
  required,
153
231
  size,
232
+ id: idProp,
154
233
  value: controlledValue,
155
234
  onChange,
156
235
  onBlur,
@@ -167,11 +246,10 @@ export function RichTextEditor<
167
246
  onImageUpload,
168
247
  onFileUpload,
169
248
  mentions,
170
- ..._restProps
171
249
  } = props;
172
250
 
173
251
  const placeholder = placeholderProp ?? t("Write something...");
174
- const { resolvedForm, knobProps, id } = useFormField({ form });
252
+ const { resolvedForm, knobProps, id } = useFormField({ form, id: idProp, compact });
175
253
  const resolvedValidators = useResolvedValidators(
176
254
  required,
177
255
  validators,
@@ -192,13 +270,27 @@ export function RichTextEditor<
192
270
  [onChange],
193
271
  );
194
272
 
273
+ const layoutBind = {
274
+ id,
275
+ label,
276
+ labelProps,
277
+ helperText,
278
+ required,
279
+ size,
280
+ knobProps,
281
+ disabled,
282
+ purpose: "description" as const,
283
+ placeholder,
284
+ };
285
+
195
286
  // ── Branch 1: Skeleton ──────────────────────────────────────────
287
+ // LC-20: FieldLayout keeps live label/helper anatomy; only the control
288
+ // chrome is the skeleton, sized to the editor's minHeight.
196
289
  if (skeleton) {
197
290
  return (
198
- <YStack gap="$2">
199
- {label && <Skeleton variant="text" width="30%" height={14} />}
291
+ <FieldLayout {...layoutBind} error={errorProp}>
200
292
  <Skeleton variant="rounded" width="100%" height={minHeight} />
201
- </YStack>
293
+ </FieldLayout>
202
294
  );
203
295
  }
204
296
 
@@ -210,47 +302,45 @@ export function RichTextEditor<
210
302
  hasError?: boolean,
211
303
  ) => {
212
304
  return (
213
- <RichEditor
214
- ref={editorRef}
215
- initialContent={currentValue}
216
- onChange={(html) => onValueChange(html)}
217
- onBlur={handleBlur}
218
- placeholder={placeholder}
219
- editable={!disabled && !readOnly}
220
- minHeight={minHeight}
221
- maxHeight={maxHeight}
222
- showToolbar={showToolbar}
223
- showBubbleToolbar={showBubbleToolbar}
224
- sections={sections}
225
- capabilities={capabilities}
226
- hasError={hasError}
227
- size={size}
228
- compact={compact}
229
- fullscreen={fullscreen}
230
- defaultFullscreen={defaultFullscreen}
231
- onFullscreenChange={onFullscreenChange}
232
- inlineEditing={inlineEditing}
233
- onImageUpload={onImageUpload}
234
- onFileUpload={onFileUpload}
235
- mentions={mentions}
236
- />
305
+ <>
306
+ <RichTextExternalValueSync
307
+ value={currentValue}
308
+ editorRef={editorRef}
309
+ capabilities={capabilities}
310
+ />
311
+ <EditorFocusTarget id={id} editorRef={editorRef} />
312
+ <RichEditor
313
+ ref={editorRef}
314
+ initialContent={currentValue}
315
+ onChange={(html) => onValueChange(html)}
316
+ onBlur={handleBlur}
317
+ placeholder={placeholder}
318
+ editable={!disabled && !readOnly}
319
+ minHeight={minHeight}
320
+ maxHeight={maxHeight}
321
+ showToolbar={showToolbar}
322
+ showBubbleToolbar={showBubbleToolbar}
323
+ sections={sections}
324
+ capabilities={capabilities}
325
+ hasError={hasError}
326
+ size={size}
327
+ compact={compact}
328
+ fullscreen={fullscreen}
329
+ defaultFullscreen={defaultFullscreen}
330
+ onFullscreenChange={onFullscreenChange}
331
+ inlineEditing={inlineEditing}
332
+ onImageUpload={onImageUpload}
333
+ onFileUpload={onFileUpload}
334
+ mentions={mentions}
335
+ />
336
+ </>
237
337
  );
238
338
  };
239
339
 
240
340
  // ── Branch 2: Standalone (no form context) ──────────────────────
241
341
  if (!resolvedForm || !name) {
242
342
  return (
243
- <FieldLayout
244
- id={id}
245
- label={label}
246
- labelProps={labelProps}
247
- error={errorProp}
248
- helperText={helperText}
249
- required={required}
250
- size={size}
251
- knobProps={knobProps}
252
- onBlur={onBlur}
253
- >
343
+ <FieldLayout {...layoutBind} error={errorProp} onBlur={onBlur}>
254
344
  {renderEditor(value, handleChange, onBlur as any, !!errorProp)}
255
345
  </FieldLayout>
256
346
  );
@@ -272,14 +362,8 @@ export function RichTextEditor<
272
362
 
273
363
  return (
274
364
  <FieldLayout
275
- id={id}
276
- label={label}
277
- labelProps={labelProps}
365
+ {...layoutBind}
278
366
  error={resolvedError}
279
- helperText={helperText}
280
- required={required}
281
- size={size}
282
- knobProps={knobProps}
283
367
  onBlur={mergeFieldHandler(field, "handleBlur", onBlur)}
284
368
  >
285
369
  {renderEditor(
@@ -1,32 +1,47 @@
1
- import { describe, it, expect, vi } from "vitest";
1
+ import { forwardRef, useImperativeHandle } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
2
3
 
3
- // ---------------------------------------------------------------------------
4
- // Mocks -- must come before imports
5
- // ---------------------------------------------------------------------------
4
+ const editorSpies = vi.hoisted(() => ({
5
+ setHTML: vi.fn(),
6
+ setMarkdown: vi.fn(),
7
+ focus: vi.fn(),
8
+ }));
6
9
 
7
- vi.mock("@multiplatform.one/forms", async (importOriginal) => {
10
+ vi.mock("@multiplatform.one/forms", async () => {
11
+ const React = await import("react");
8
12
  return {
9
- ...(await importOriginal<Record<string, unknown>>()),
10
- Field: ({ children, ...props }: any) => {
13
+ Field: ({ children, defaultValue }: any) => {
14
+ const [value, setValue] = React.useState(defaultValue ?? "");
11
15
  const field = {
12
- state: { value: props.defaultValue ?? "", meta: { errors: [] } },
13
- handleChange: vi.fn(),
16
+ state: { value, meta: { errors: [] } },
17
+ handleChange: vi.fn((next: string) => setValue(next)),
14
18
  handleBlur: vi.fn(),
15
19
  };
16
- return <div data-testid="tanstack-field">{children(field)}</div>;
20
+ return (
21
+ <div data-testid="tanstack-field">
22
+ <button type="button" data-testid="simulate-reset" onClick={() => setValue("")}>
23
+ reset
24
+ </button>
25
+ {children(field)}
26
+ </div>
27
+ );
17
28
  },
18
29
  FieldLayout: ({ children, label, error, ...props }: any) => (
19
30
  <div
20
31
  data-testid="field-layout"
21
32
  data-label={label}
22
33
  data-error={error || undefined}
23
- data-skeleton={props.skeleton || undefined}
34
+ data-id={props.id}
35
+ data-gap={props.knobProps?.gap?.gap}
36
+ data-disabled={props.disabled ? "true" : undefined}
37
+ data-purpose={props.purpose}
38
+ data-placeholder={props.placeholder}
24
39
  >
25
40
  {children}
26
41
  </div>
27
42
  ),
28
43
  Skeleton: ({ variant, width, height, ..._props }: any) => (
29
- <div data-testid="skeleton" data-variant={variant} style={{ width, height }} />
44
+ <div data-testid="skeleton" data-variant={variant} data-width={width} data-height={height} />
30
45
  ),
31
46
  getFieldError: (field: any, propError: any) => {
32
47
  const fieldError = field.state.meta.errors.length
@@ -34,7 +49,7 @@ vi.mock("@multiplatform.one/forms", async (importOriginal) => {
34
49
  : undefined;
35
50
  return propError ?? fieldError;
36
51
  },
37
- useFormField: ({ form }: any) => ({
52
+ useFormField: ({ form, id, compact }: any) => ({
38
53
  resolvedForm: form || undefined,
39
54
  knobProps: {
40
55
  body: {},
@@ -46,9 +61,9 @@ vi.mock("@multiplatform.one/forms", async (importOriginal) => {
46
61
  textAccentColor: "$color",
47
62
  transition: undefined,
48
63
  heading: {},
49
- gap: { gap: "$2" },
64
+ gap: { gap: compact ? "$1" : "$2" },
50
65
  },
51
- id: "test-id",
66
+ id: id || "test-id",
52
67
  }),
53
68
  useResolvedValidators: (
54
69
  required: boolean | undefined,
@@ -72,129 +87,50 @@ vi.mock("@multiplatform.one/forms", async (importOriginal) => {
72
87
  handler?.(...args);
73
88
  };
74
89
  },
75
- clampRadiusForLargeComponent: (radiusToken: string | undefined, maxTokenNum = 4) => {
76
- if (!radiusToken) return "$3";
77
- if (radiusToken === "$0") return 0;
78
- if (typeof radiusToken === "string" && radiusToken.startsWith("$")) {
79
- const num = Number.parseInt(radiusToken.slice(1), 10);
80
- return num > maxTokenNum ? `$${maxTokenNum}` : radiusToken;
81
- }
82
- return radiusToken;
83
- },
84
- Button: (props: any) => <button>{props.children}</button>,
85
90
  };
86
91
  });
87
92
 
88
- vi.mock("@multiplatform.one/components", async (importOriginal) => ({
89
- ...(await importOriginal<Record<string, unknown>>()),
90
- Tooltip: ({ children }: any) => children,
91
- }));
92
-
93
- vi.mock("@multiplatform.one/theme", async (importOriginal) => ({
94
- ...(await importOriginal<Record<string, unknown>>()),
95
- useResolvedKnobs: () => ({
96
- knobProps: {
97
- body: {},
98
- borderRadius: { borderRadius: "$3", borderWidth: 1 },
99
- elevation: undefined,
100
- sizeToken: "$4",
101
- outlined: false,
102
- inputBackground: "$color2",
103
- textAccentColor: "$color",
104
- transition: undefined,
105
- heading: {},
106
- gap: { gap: "$2" },
107
- },
108
- control: {
109
- hoverKnobProps: undefined,
110
- pressKnobProps: undefined,
111
- focusKnobProps: undefined,
112
- focusVisibleKnobProps: undefined,
113
- },
114
- }),
115
- }));
116
-
117
- vi.mock("@tiptap/react/menus", () => ({
118
- BubbleMenu: ({ children }: any) => <div data-testid="bubble-menu">{children}</div>,
119
- }));
120
-
121
- const mockEditor = {
122
- isActive: vi.fn().mockReturnValue(false),
123
- getAttributes: vi.fn().mockReturnValue({}),
124
- can: vi.fn().mockReturnValue({ undo: () => false, redo: () => false }),
125
- getHTML: vi.fn().mockReturnValue("<p></p>"),
126
- getJSON: vi.fn().mockReturnValue({ type: "doc", content: [] }),
127
- chain: vi.fn().mockReturnValue({
128
- focus: vi.fn().mockReturnValue({
129
- toggleBold: vi.fn().mockReturnValue({ run: vi.fn() }),
130
- }),
93
+ vi.mock("../RichEditor", () => ({
94
+ RichEditor: forwardRef((props: any, ref) => {
95
+ useImperativeHandle(ref, () => ({
96
+ setHTML: editorSpies.setHTML,
97
+ setMarkdown: editorSpies.setMarkdown,
98
+ focus: editorSpies.focus,
99
+ blur: vi.fn(),
100
+ getHTML: () => props.initialContent ?? "",
101
+ getJSON: () => null,
102
+ getMarkdown: () => "",
103
+ getState: () => ({}),
104
+ executeCommand: vi.fn(),
105
+ }));
106
+ return (
107
+ <div
108
+ data-testid="editor-content"
109
+ data-compact={props.compact ? "true" : "false"}
110
+ data-editable={String(props.editable !== false)}
111
+ data-initial={props.initialContent}
112
+ data-bubble={props.showBubbleToolbar ? "true" : undefined}
113
+ data-sections={props.sections ? JSON.stringify(props.sections) : undefined}
114
+ />
115
+ );
131
116
  }),
132
- commands: {
133
- setContent: vi.fn(),
134
- focus: vi.fn(),
135
- blur: vi.fn(),
136
- },
137
- setEditable: vi.fn(),
138
- storage: {
139
- mediaUpload: { handlers: {} },
140
- suggestionChrome: { value: null },
141
- },
142
- };
143
-
144
- vi.mock("@tiptap/react", () => ({
145
- useEditor: vi.fn().mockReturnValue(mockEditor),
146
- EditorContent: ({ editor: _editor }: any) => <div data-testid="editor-content">Editor</div>,
147
- }));
148
-
149
- vi.mock("@tiptap/starter-kit", () => ({
150
- default: { configure: vi.fn().mockReturnValue({}) },
151
- }));
152
- vi.mock("@tiptap/extension-underline", () => ({ default: {} }));
153
- vi.mock("@tiptap/extension-link", () => ({
154
- default: { configure: vi.fn().mockReturnValue({}) },
155
- }));
156
- vi.mock("@tiptap/extension-placeholder", () => ({
157
- default: { configure: vi.fn().mockReturnValue({}) },
158
117
  }));
159
- vi.mock("@tiptap/extension-text-align", () => ({
160
- default: { configure: vi.fn().mockReturnValue({}) },
161
- }));
162
- vi.mock("@tiptap/extension-color", () => ({ Color: {} }));
163
- vi.mock("@tiptap/extension-text-style", () => ({ TextStyle: {} }));
164
- vi.mock("@tiptap/extension-highlight", () => ({
165
- Highlight: { configure: vi.fn().mockReturnValue({}) },
166
- }));
167
- vi.mock("@tiptap/extension-subscript", () => ({ Subscript: {} }));
168
- vi.mock("@tiptap/extension-superscript", () => ({ Superscript: {} }));
169
- vi.mock("@tiptap/extension-task-list", () => ({ default: {} }));
170
- vi.mock("@tiptap/extension-task-item", () => ({
171
- default: { configure: vi.fn().mockReturnValue({}) },
172
- }));
173
- vi.mock("@tiptap/extension-image", () => ({
174
- default: { configure: vi.fn().mockReturnValue({}) },
175
- }));
176
-
177
- // ---------------------------------------------------------------------------
178
- // Imports (after mocks)
179
- // ---------------------------------------------------------------------------
180
118
 
119
+ import { act } from "@testing-library/react";
181
120
  import { renderWithProviders } from "@multiplatform.one/test-utils";
182
121
 
183
- const { RichTextEditor } = await import("./index");
184
-
185
- // ---------------------------------------------------------------------------
186
- // Tests
187
- // ---------------------------------------------------------------------------
122
+ const { RichTextEditor, richTextIsEmpty } = await import("./index");
188
123
 
189
124
  describe("RichTextEditor form-integrated field", () => {
190
- it("renders skeleton loading state when skeleton={true}", () => {
191
- const { getAllByTestId, queryByTestId } = renderWithProviders(
192
- <RichTextEditor skeleton label="Content" />,
125
+ it("renders skeleton as FieldLayout + control chrome, never a 14px label bar", () => {
126
+ const { getAllByTestId, getByTestId, queryByTestId } = renderWithProviders(
127
+ <RichTextEditor skeleton label="Content" minHeight={180} />,
193
128
  );
194
129
  const skeletons = getAllByTestId("skeleton");
195
- // Should render label skeleton + editor skeleton
196
- expect(skeletons.length).toBe(2);
197
- // Should NOT render the editor content
130
+ expect(skeletons).toHaveLength(1);
131
+ expect(skeletons[0]).toHaveAttribute("data-variant", "rounded");
132
+ expect(skeletons[0]).toHaveAttribute("data-height", "180");
133
+ expect(getByTestId("field-layout")).toHaveAttribute("data-label", "Content");
198
134
  expect(queryByTestId("editor-content")).toBeNull();
199
135
  });
200
136
 
@@ -203,10 +139,8 @@ describe("RichTextEditor form-integrated field", () => {
203
139
  const { container } = renderWithProviders(
204
140
  <RichTextEditor label="Content" onChange={handleChange} placeholder="Write something..." />,
205
141
  );
206
- // Should render FieldLayout wrapper
207
142
  const fieldLayout = container.querySelector("[data-testid='field-layout']");
208
143
  expect(fieldLayout).toBeTruthy();
209
- // Should render the RichEditor inside
210
144
  const editorContent = container.querySelector("[data-testid='editor-content']");
211
145
  expect(editorContent).toBeTruthy();
212
146
  });
@@ -216,31 +150,91 @@ describe("RichTextEditor form-integrated field", () => {
216
150
  const { getByTestId } = renderWithProviders(
217
151
  <RichTextEditor form={mockForm} name="content" label="Content" required />,
218
152
  );
219
- // Should render the TanStack Field wrapper
220
- const field = getByTestId("tanstack-field");
221
- expect(field).toBeTruthy();
222
- // Should render FieldLayout inside the field
223
- const fieldLayout = getByTestId("field-layout");
224
- expect(fieldLayout).toBeTruthy();
153
+ expect(getByTestId("tanstack-field")).toBeTruthy();
154
+ expect(getByTestId("field-layout")).toBeTruthy();
225
155
  });
226
156
 
227
- it("custom empty check recognizes '<p></p>' and '' as empty for required validation", () => {
228
- // The custom isEmpty function should treat '<p></p>' and '' as empty
229
- const isEmpty = (v: unknown) => !v || v === "<p></p>" || v === "";
230
-
231
- expect(isEmpty("")).toBe(true);
232
- expect(isEmpty("<p></p>")).toBe(true);
233
- expect(isEmpty(null)).toBe(true);
234
- expect(isEmpty(undefined)).toBe(true);
235
- expect(isEmpty("<p>Hello</p>")).toBe(false);
236
- expect(isEmpty("<h1>Title</h1>")).toBe(false);
157
+ it("treats Tiptap empty markup as empty for required validation", () => {
158
+ expect(richTextIsEmpty("")).toBe(true);
159
+ expect(richTextIsEmpty("<p></p>")).toBe(true);
160
+ expect(richTextIsEmpty(null)).toBe(true);
161
+ expect(richTextIsEmpty(undefined)).toBe(true);
162
+ expect(richTextIsEmpty("<p>Hello</p>")).toBe(false);
163
+ expect(richTextIsEmpty("<h1>Title</h1>")).toBe(false);
237
164
  });
238
165
 
239
166
  it("passes showBubbleToolbar and sections props through to RichEditor", () => {
240
- const { container } = renderWithProviders(
167
+ const { getByTestId } = renderWithProviders(
241
168
  <RichTextEditor label="Content" showBubbleToolbar sections={["formatting", "list"]} />,
242
169
  );
243
- // Should render without error (props are passed through)
244
- expect(container.firstChild).toBeTruthy();
170
+ const editor = getByTestId("editor-content");
171
+ expect(editor).toHaveAttribute("data-bubble", "true");
172
+ expect(editor).toHaveAttribute("data-sections", JSON.stringify(["formatting", "list"]));
173
+ });
174
+
175
+ it("feeds compact into useFormField so layout gap and editor density both step down", () => {
176
+ const { getByTestId, rerender } = renderWithProviders(<RichTextEditor label="Content" />);
177
+ expect(getByTestId("field-layout")).toHaveAttribute("data-gap", "$2");
178
+ expect(getByTestId("editor-content")).toHaveAttribute("data-compact", "false");
179
+ rerender(<RichTextEditor label="Content" compact />);
180
+ expect(getByTestId("field-layout")).toHaveAttribute("data-gap", "$1");
181
+ expect(getByTestId("editor-content")).toHaveAttribute("data-compact", "true");
182
+ });
183
+
184
+ it("pushes controlled value changes into the editor after mount", () => {
185
+ editorSpies.setHTML.mockClear();
186
+ const { rerender } = renderWithProviders(<RichTextEditor label="Content" value="<p>one</p>" />);
187
+ expect(editorSpies.setHTML).not.toHaveBeenCalled();
188
+ rerender(<RichTextEditor label="Content" value="<p>two</p>" />);
189
+ expect(editorSpies.setHTML).toHaveBeenCalledWith("<p>two</p>");
190
+ });
191
+
192
+ it("pushes markdown capability values through setMarkdown", () => {
193
+ editorSpies.setMarkdown.mockClear();
194
+ const { rerender } = renderWithProviders(
195
+ <RichTextEditor label="Content" capabilities="markdown" value="hello" />,
196
+ );
197
+ rerender(<RichTextEditor label="Content" capabilities="markdown" value="world" />);
198
+ expect(editorSpies.setMarkdown).toHaveBeenCalledWith("world");
199
+ });
200
+
201
+ it("clears the editor when the form field value is reset", async () => {
202
+ editorSpies.setHTML.mockClear();
203
+ const { getByTestId } = renderWithProviders(
204
+ <RichTextEditor
205
+ form={{ store: {} } as any}
206
+ name="content"
207
+ defaultValue="<p>hello</p>"
208
+ label="Content"
209
+ />,
210
+ );
211
+ expect(getByTestId("editor-content")).toHaveAttribute("data-initial", "<p>hello</p>");
212
+ await act(async () => {
213
+ getByTestId("simulate-reset").click();
214
+ });
215
+ expect(editorSpies.setHTML).toHaveBeenCalledWith("");
216
+ });
217
+
218
+ it("associates the field id with a labelable control that forwards focus", () => {
219
+ editorSpies.focus.mockClear();
220
+ const { container, getByTestId } = renderWithProviders(
221
+ <RichTextEditor id="notes" label="Content" />,
222
+ );
223
+ expect(getByTestId("field-layout")).toHaveAttribute("data-id", "notes");
224
+ const target = container.querySelector("#notes") as HTMLTextAreaElement | null;
225
+ expect(target).toBeTruthy();
226
+ target?.focus();
227
+ expect(editorSpies.focus).toHaveBeenCalled();
228
+ });
229
+
230
+ it("passes disabled to FieldLayout and makes the editor non-editable", () => {
231
+ const { getByTestId } = renderWithProviders(<RichTextEditor label="Content" disabled />);
232
+ expect(getByTestId("field-layout")).toHaveAttribute("data-disabled", "true");
233
+ expect(getByTestId("editor-content")).toHaveAttribute("data-editable", "false");
234
+ });
235
+
236
+ it("declares description purpose so the field stretches full width", () => {
237
+ const { getByTestId } = renderWithProviders(<RichTextEditor label="Content" />);
238
+ expect(getByTestId("field-layout")).toHaveAttribute("data-purpose", "description");
245
239
  });
246
240
  });