@multiplatform.one/rich-text 7.4.0 → 7.5.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/dist/cjs/RichTextEditor/index.cjs +115 -50
- package/dist/cjs/RichTextEditor/index.native.js +119 -53
- package/dist/cjs/RichTextEditor/index.native.js.map +1 -1
- package/dist/cjs/toolbar/HeadingPicker.cjs +0 -7
- package/dist/cjs/toolbar/HeadingPicker.native.js +17 -13
- package/dist/cjs/toolbar/HeadingPicker.native.js.map +1 -1
- package/dist/cjs/toolbar/ListPicker.cjs +0 -7
- package/dist/cjs/toolbar/ListPicker.native.js +11 -12
- package/dist/cjs/toolbar/ListPicker.native.js.map +1 -1
- package/dist/cjs/toolbar/MoreDropdown.cjs +0 -7
- package/dist/cjs/toolbar/MoreDropdown.native.js +11 -12
- package/dist/cjs/toolbar/MoreDropdown.native.js.map +1 -1
- package/dist/esm/RichTextEditor/index.mjs +117 -53
- package/dist/esm/RichTextEditor/index.mjs.map +1 -1
- package/dist/esm/RichTextEditor/index.native.js +121 -56
- package/dist/esm/RichTextEditor/index.native.js.map +1 -1
- package/dist/esm/toolbar/HeadingPicker.mjs +0 -7
- package/dist/esm/toolbar/HeadingPicker.mjs.map +1 -1
- package/dist/esm/toolbar/HeadingPicker.native.js +17 -13
- package/dist/esm/toolbar/HeadingPicker.native.js.map +1 -1
- package/dist/esm/toolbar/ListPicker.mjs +0 -7
- package/dist/esm/toolbar/ListPicker.mjs.map +1 -1
- package/dist/esm/toolbar/ListPicker.native.js +11 -12
- package/dist/esm/toolbar/ListPicker.native.js.map +1 -1
- package/dist/esm/toolbar/MoreDropdown.mjs +0 -7
- package/dist/esm/toolbar/MoreDropdown.mjs.map +1 -1
- package/dist/esm/toolbar/MoreDropdown.native.js +11 -12
- package/dist/esm/toolbar/MoreDropdown.native.js.map +1 -1
- package/dist/jsx/RichTextEditor/index.mjs +117 -53
- package/dist/jsx/RichTextEditor/index.mjs.map +1 -1
- package/dist/jsx/RichTextEditor/index.native.js +119 -53
- package/dist/jsx/RichTextEditor/index.native.js.map +1 -1
- package/dist/jsx/toolbar/HeadingPicker.mjs +0 -7
- package/dist/jsx/toolbar/HeadingPicker.mjs.map +1 -1
- package/dist/jsx/toolbar/HeadingPicker.native.js +17 -13
- package/dist/jsx/toolbar/HeadingPicker.native.js.map +1 -1
- package/dist/jsx/toolbar/ListPicker.mjs +0 -7
- package/dist/jsx/toolbar/ListPicker.mjs.map +1 -1
- package/dist/jsx/toolbar/ListPicker.native.js +11 -12
- package/dist/jsx/toolbar/ListPicker.native.js.map +1 -1
- package/dist/jsx/toolbar/MoreDropdown.mjs +0 -7
- package/dist/jsx/toolbar/MoreDropdown.mjs.map +1 -1
- package/dist/jsx/toolbar/MoreDropdown.native.js +11 -12
- package/dist/jsx/toolbar/MoreDropdown.native.js.map +1 -1
- package/package.json +8 -8
- package/src/RichEditor.stories.tsx +36 -1
- package/src/RichTextEditor/index.tsx +134 -50
- package/src/RichTextEditor/richTextEditor.spec.tsx +141 -147
- package/src/toolbar/HeadingPicker.tsx +15 -14
- package/src/toolbar/ListPicker.tsx +9 -13
- package/src/toolbar/MoreDropdown.tsx +9 -13
- package/types/RichTextEditor/index.d.ts +6 -0
- package/types/RichTextEditor/index.d.ts.map +1 -1
- package/types/toolbar/HeadingPicker.d.ts.map +1 -1
- package/types/toolbar/ListPicker.d.ts.map +1 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
<
|
|
199
|
-
{label && <Skeleton variant="text" width="30%" height={14} />}
|
|
291
|
+
<FieldLayout {...layoutBind} error={errorProp}>
|
|
200
292
|
<Skeleton variant="rounded" width="100%" height={minHeight} />
|
|
201
|
-
</
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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 {
|
|
1
|
+
import { forwardRef, useImperativeHandle } from "react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
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 (
|
|
10
|
+
vi.mock("@multiplatform.one/forms", async () => {
|
|
11
|
+
const React = await import("react");
|
|
8
12
|
return {
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
Field: ({ children, defaultValue }: any) => {
|
|
14
|
+
const [value, setValue] = React.useState(defaultValue ?? "");
|
|
11
15
|
const field = {
|
|
12
|
-
state: { value
|
|
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
|
|
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-
|
|
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}
|
|
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("
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
196
|
-
expect(skeletons
|
|
197
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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("
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
expect(
|
|
232
|
-
expect(
|
|
233
|
-
expect(
|
|
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 {
|
|
167
|
+
const { getByTestId } = renderWithProviders(
|
|
241
168
|
<RichTextEditor label="Content" showBubbleToolbar sections={["formatting", "list"]} />,
|
|
242
169
|
);
|
|
243
|
-
|
|
244
|
-
expect(
|
|
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
|
});
|