@multiplatform.one/frappe-ui 6.3.0 → 6.4.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": "@multiplatform.one/frappe-ui",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "description": "Pre-wired Tamagui components for Frappe doctypes with live data",
5
5
  "keywords": [
6
6
  "cross-platform",
@@ -52,6 +52,13 @@
52
52
  "import": "./dist/esm/index.mjs",
53
53
  "require": "./dist/cjs/index.cjs",
54
54
  "default": "./dist/esm/index.mjs"
55
+ },
56
+ "./dependsOn": {
57
+ "source": "./src/dependsOn.ts",
58
+ "types": "./types/dependsOn.d.ts",
59
+ "import": "./dist/esm/dependsOn.mjs",
60
+ "require": "./dist/cjs/dependsOn.cjs",
61
+ "default": "./src/dependsOn.ts"
55
62
  }
56
63
  },
57
64
  "publishConfig": {
@@ -71,17 +78,17 @@
71
78
  "html5-qrcode": "^2.3.8",
72
79
  "signature_pad": "^5.1.3",
73
80
  "tamagui": "2.0.0-rc.41",
74
- "@multiplatform.one/components": "6.3.0",
75
- "@multiplatform.one/desktop": "6.3.0",
76
- "@multiplatform.one/forms": "6.3.0",
77
- "@multiplatform.one/frappe": "6.3.0",
78
- "@multiplatform.one/i18n": "6.3.0",
79
- "@multiplatform.one/platform": "6.3.0",
80
- "@multiplatform.one/rich-text": "6.3.0",
81
- "@multiplatform.one/router": "6.3.0",
82
- "@multiplatform.one/theme": "6.3.0",
83
- "@multiplatform.one/store": "6.3.0",
84
- "@multiplatform.one/table": "6.3.0"
81
+ "@multiplatform.one/components": "6.4.0",
82
+ "@multiplatform.one/frappe": "6.4.0",
83
+ "@multiplatform.one/desktop": "6.4.0",
84
+ "@multiplatform.one/forms": "6.4.0",
85
+ "@multiplatform.one/i18n": "6.4.0",
86
+ "@multiplatform.one/router": "6.4.0",
87
+ "@multiplatform.one/store": "6.4.0",
88
+ "@multiplatform.one/rich-text": "6.4.0",
89
+ "@multiplatform.one/table": "6.4.0",
90
+ "@multiplatform.one/theme": "6.4.0",
91
+ "@multiplatform.one/platform": "6.4.0"
85
92
  },
86
93
  "devDependencies": {
87
94
  "@tamagui/build": "2.0.0-rc.41",
@@ -94,9 +101,9 @@
94
101
  "react-dom": "19.2.5",
95
102
  "react-i18next": "^16.6.6",
96
103
  "vitest": "^4.1.5",
97
- "@multiplatform.one/config": "6.3.0",
98
- "@multiplatform.one/test-utils": "6.3.0",
99
- "@multiplatform.one/storybook": "6.3.0"
104
+ "@multiplatform.one/config": "6.4.0",
105
+ "@multiplatform.one/test-utils": "6.4.0",
106
+ "@multiplatform.one/storybook": "6.4.0"
100
107
  },
101
108
  "peerDependencies": {
102
109
  "@react-native-community/datetimepicker": "^8.0.1",
@@ -31,7 +31,9 @@ export const KPICardDefault = () => (
31
31
  />
32
32
  );
33
33
 
34
- KPICardDefault.storyName = "Main";
34
+ // Not "Main": SalesDashboard below owns that name; two stories sharing one
35
+ // name collide to a single lost-pixel shot file, silently untesting one.
36
+ KPICardDefault.storyName = "KPI Card";
35
37
  // Chart Card Stories
36
38
  export const BarChart = () => (
37
39
  <ChartCard
@@ -15,7 +15,25 @@ import React, { useMemo, useCallback, useState, useEffect, useRef } from "react"
15
15
  import { useTranslation } from "react-i18next";
16
16
  import { Spinner } from "@multiplatform.one/forms";
17
17
  import { Button, Text, XStack, YStack, type YStackProps, H3 } from "tamagui";
18
- import { useResolvedKnobs } from "@multiplatform.one/theme";
18
+ import {
19
+ MIN_PRESS_TARGET,
20
+ pressTargetHitSlop,
21
+ pressTargetStyle,
22
+ useResolvedKnobs,
23
+ } from "@multiplatform.one/theme";
24
+
25
+ // DG-LAY-04 / D-02 press-floor: the chromeless $2 refresh button paints
26
+ // ~28×28; the press box is floored at 44 via the house pressTarget* channel
27
+ // (grown transparent box + negative outset + hitSlop). Layout stays put; the
28
+ // only adjacent node is non-interactive text, so no target overlap.
29
+ const REFRESH_VISUAL = 28;
30
+ const REFRESH_OUTSET = Math.ceil((MIN_PRESS_TARGET - REFRESH_VISUAL) / 2);
31
+ const refreshPressFloorProps = {
32
+ ...pressTargetStyle(),
33
+ marginVertical: -REFRESH_OUTSET,
34
+ marginHorizontal: -REFRESH_OUTSET,
35
+ hitSlop: pressTargetHitSlop(REFRESH_VISUAL),
36
+ } as const;
19
37
  import { useFrappeConfig } from "./FrappeProvider";
20
38
 
21
39
  /**
@@ -209,6 +227,7 @@ function LiveDashboardContent<TDoc extends { name: string; doctype: string }>({
209
227
  disabled={isRefreshing || isLiveLoading}
210
228
  chromeless
211
229
  aria-label={t("Refresh")}
230
+ {...refreshPressFloorProps}
212
231
  />
213
232
  </XStack>
214
233
  )}
@@ -319,6 +338,7 @@ export function FrappeDashboard<TDoc extends { name: string; doctype: string }>(
319
338
  disabled={isRefreshing}
320
339
  chromeless
321
340
  aria-label={t("Refresh")}
341
+ {...refreshPressFloorProps}
322
342
  />
323
343
  </XStack>
324
344
  </XStack>
@@ -17,12 +17,31 @@ import {
17
17
  } from "tamagui";
18
18
  import { SavedFilterDropdown, useSavedFilters, type ParsedFilter } from "@multiplatform.one/table";
19
19
  import { ViewSwitcher } from "@multiplatform.one/components";
20
- import { useResolvedKnobs } from "@multiplatform.one/theme";
20
+ import {
21
+ MIN_PRESS_TARGET,
22
+ pressTargetHitSlop,
23
+ pressTargetStyle,
24
+ useResolvedKnobs,
25
+ } from "@multiplatform.one/theme";
21
26
  import { storage } from "@multiplatform.one/store";
22
27
  import { useFrappeConfig } from "./FrappeProvider";
23
28
  import { FrappeList } from "./FrappeList";
24
29
  import { FrappeTable } from "./FrappeTable";
25
30
 
31
+ // DG-LAY-04 / D-02 press-floor: the chromeless $2 refresh button paints
32
+ // ~28×28; the press box is floored at 44 via the house pressTarget* channel
33
+ // (grown transparent box + negative outset + hitSlop). The horizontal outset
34
+ // (8px) stays under the toolbar gap, so the enlarged target never reaches the
35
+ // adjacent Add button's box.
36
+ const REFRESH_VISUAL = 28;
37
+ const REFRESH_OUTSET = Math.ceil((MIN_PRESS_TARGET - REFRESH_VISUAL) / 2);
38
+ const refreshPressFloorProps = {
39
+ ...pressTargetStyle(),
40
+ marginVertical: -REFRESH_OUTSET,
41
+ marginHorizontal: -REFRESH_OUTSET,
42
+ hitSlop: pressTargetHitSlop(REFRESH_VISUAL),
43
+ } as const;
44
+
26
45
  interface ListFieldConfig {
27
46
  key: string;
28
47
  label?: string;
@@ -481,6 +500,7 @@ export function FrappeListView<TDoc extends { name: string; doctype: string }>({
481
500
  icon={ArrowsClockwiseIcon}
482
501
  onPress={handleRefresh}
483
502
  aria-label={t("Refresh")}
503
+ {...refreshPressFloorProps}
484
504
  />
485
505
  {onAdd && (
486
506
  <Button size="$3" theme="accent" icon={PlusIcon} onPress={onAdd}>
@@ -1064,7 +1064,7 @@ export function fieldtypeToTableField(
1064
1064
  onChange: onCellChange,
1065
1065
  doctype: rowDoctype,
1066
1066
  compact: true,
1067
- disabled: !rowDoctype || context?.disabled,
1067
+ disabled: Boolean(!rowDoctype || context?.disabled),
1068
1068
  // D-18.4: read-only cells block commits, so the combobox must
1069
1069
  // render its non-interactive representation.
1070
1070
  readOnly: context?.readOnly,
@@ -1,11 +1,13 @@
1
1
  /**
2
- * MarkdownEditorField contract specs: canonical onChange emission (LC-14),
3
- * readOnly/disabled gating, table-cell chromeless branch, skeleton branch,
4
- * and registry resolution for the "Markdown Editor" fieldtype.
2
+ * MarkdownEditorField contract specs: rich editor in markdown capabilities
3
+ * (LC-52 MARKDOWN-EXPRESSIBLE-EDITING no highlight/underline/color
4
+ * controls), canonical onChange emitting markdown (LC-14), readOnly/disabled
5
+ * gating, table-cell chromeless branch, skeleton branch, and registry
6
+ * resolution for the "Markdown Editor" fieldtype.
5
7
  */
6
8
  import { TableCellContext } from "@multiplatform.one/forms";
7
9
  import { renderWithProviders } from "@multiplatform.one/test-utils";
8
- import { act, cleanup, fireEvent } from "@testing-library/react";
10
+ import { act, cleanup, fireEvent, waitFor } from "@testing-library/react";
9
11
  import type React from "react";
10
12
  import { afterEach, describe, expect, it, vi } from "vitest";
11
13
  import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
@@ -13,63 +15,85 @@ import { MarkdownEditorField } from ".";
13
15
 
14
16
  afterEach(cleanup);
15
17
 
18
+ const getProseMirror = (container: HTMLElement) =>
19
+ container.querySelector<HTMLElement>('[contenteditable="true"]');
20
+
16
21
  describe("MarkdownEditorField (standalone)", () => {
17
- it("emits the markdown source through canonical onChange", async () => {
18
- const onChange = vi.fn();
22
+ it("renders the rich text editor (WYSIWYG), not a source textarea", async () => {
19
23
  const result = renderWithProviders(
20
- <MarkdownEditorField label="Notes" value="" onChange={onChange} />,
24
+ <MarkdownEditorField label="Notes" value="# Hello" onChange={() => {}} />,
21
25
  );
22
- const textarea = result.getTextArea();
23
- expect(textarea).toBeTruthy();
24
- await act(async () => {
25
- if (textarea) fireEvent.change(textarea, { target: { value: "# Hello" } });
26
- });
27
- expect(onChange).toHaveBeenCalledWith("# Hello");
26
+ await waitFor(() => expect(getProseMirror(result.container)).toBeTruthy());
27
+ expect(result.getTextArea()).toBeNull();
28
+ // markdown value parsed into rich nodes
29
+ expect(result.container.querySelector("h1")?.textContent).toBe("Hello");
28
30
  });
29
31
 
30
- it("readOnly gates onChange (no emission even if the DOM event fires)", async () => {
32
+ it("markdown capabilities: no underline/highlight/color toolbar controls (LC-52)", async () => {
33
+ const result = renderWithProviders(
34
+ <MarkdownEditorField label="Notes" value="hello" onChange={() => {}} />,
35
+ );
36
+ await waitFor(() => expect(result.queryByLabelText("Bold")).toBeTruthy());
37
+ expect(result.queryByLabelText("Underline")).toBeNull();
38
+ expect(result.queryByLabelText("Text Color")).toBeNull();
39
+ expect(result.queryByLabelText("Highlight Color")).toBeNull();
40
+ // markdown-expressible controls stay
41
+ expect(result.queryByLabelText("Italic")).toBeTruthy();
42
+ expect(result.queryByLabelText("Blockquote")).toBeTruthy();
43
+ });
44
+
45
+ it("emits markdown (not HTML) through canonical onChange", async () => {
31
46
  const onChange = vi.fn();
32
47
  const result = renderWithProviders(
33
- <TableCellContext.Provider value={{ inTableCell: true, editable: true }}>
34
- <MarkdownEditorField label="Notes" value="original" readOnly onChange={onChange} />
35
- </TableCellContext.Provider>,
48
+ <MarkdownEditorField label="Notes" value="hello" onChange={onChange} />,
36
49
  );
37
- const textarea = result.getTextArea();
38
- expect(textarea).toBeTruthy();
50
+ await waitFor(() => expect(result.queryByLabelText("Blockquote")).toBeTruthy());
39
51
  await act(async () => {
40
- if (textarea) fireEvent.change(textarea, { target: { value: "mutated" } });
52
+ fireEvent.click(result.getByLabelText("Blockquote"));
41
53
  });
42
- expect(onChange).not.toHaveBeenCalled();
54
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
55
+ const emitted = onChange.mock.calls.at(-1)?.[0] as string;
56
+ expect(emitted).toContain("> hello");
57
+ expect(emitted).not.toContain("<blockquote>");
43
58
  });
44
59
 
45
- it("disabled gates onChange", async () => {
60
+ it("readOnly renders a non-editable surface without emitting changes", async () => {
46
61
  const onChange = vi.fn();
47
62
  const result = renderWithProviders(
48
- <TableCellContext.Provider value={{ inTableCell: true, editable: true }}>
49
- <MarkdownEditorField label="Notes" value="original" disabled onChange={onChange} />
50
- </TableCellContext.Provider>,
63
+ <MarkdownEditorField label="Notes" value="original" readOnly onChange={onChange} />,
51
64
  );
52
- const textarea = result.getTextArea();
53
- await act(async () => {
54
- if (textarea) fireEvent.change(textarea, { target: { value: "mutated" } });
55
- });
65
+ await waitFor(() =>
66
+ expect(result.container.querySelector('[contenteditable="false"]')).toBeTruthy(),
67
+ );
68
+ expect(getProseMirror(result.container)).toBeNull();
56
69
  expect(onChange).not.toHaveBeenCalled();
57
70
  });
58
71
 
59
- it("renders chromeless (bare source pane, no Write/Preview tabs) in table cells", () => {
72
+ it("disabled renders a non-editable surface", async () => {
73
+ const result = renderWithProviders(
74
+ <MarkdownEditorField label="Notes" value="original" disabled onChange={() => {}} />,
75
+ );
76
+ await waitFor(() =>
77
+ expect(result.container.querySelector('[contenteditable="false"]')).toBeTruthy(),
78
+ );
79
+ expect(getProseMirror(result.container)).toBeNull();
80
+ });
81
+
82
+ it("renders chromeless (no fixed formatting toolbar) in table cells", async () => {
60
83
  const result = renderWithProviders(
61
84
  <TableCellContext.Provider value={{ inTableCell: true, editable: true }}>
62
85
  <MarkdownEditorField label="Notes" value="# cell" onChange={() => {}} />
63
86
  </TableCellContext.Provider>,
64
87
  );
65
- expect(result.getTextArea()).toBeTruthy();
66
- expect(result.queryByText("Preview")).toBeNull();
88
+ await waitFor(() => expect(getProseMirror(result.container)).toBeTruthy());
89
+ expect(result.queryByLabelText("Formatting toolbar")).toBeNull();
67
90
  });
68
91
 
69
92
  it("skeleton branch renders no editable surface", () => {
70
93
  const result = renderWithProviders(
71
94
  <MarkdownEditorField label="Notes" skeleton value="# hidden" onChange={() => {}} />,
72
95
  );
96
+ expect(getProseMirror(result.container)).toBeNull();
73
97
  expect(result.getTextArea()).toBeNull();
74
98
  });
75
99
  });
@@ -9,14 +9,14 @@ const FIXTURE = `# Release notes
9
9
 
10
10
  Dedicated **Markdown Editor** field for the Frappe \`Markdown Editor\` fieldtype.
11
11
 
12
- - Live preview through the house renderer
13
- - Side-by-side above the md breakpoint, tabs below it
12
+ - WYSIWYG editing via the canonical rich text editor
13
+ - Constrained to what ~~HTML~~ *markdown* can express (LC-52)
14
14
  - Full field contract: label / hint / error / readOnly / skeleton
15
15
 
16
- > Write on the left, read on the right.
16
+ > Serializes to and from a GFM markdown string.
17
17
 
18
18
  \`\`\`python
19
- print("fenced code renders too")
19
+ print("fenced code works too")
20
20
  \`\`\`
21
21
  `;
22
22
 
@@ -28,7 +28,7 @@ export default {
28
28
  docs: {
29
29
  description: {
30
30
  component:
31
- "Frappe `Markdown Editor` fieldtype: markdown source pane + live preview (house @multiplatform.one/markdown renderer). Side-by-side md container width, Write/Preview tabs below it.",
31
+ "Frappe `Markdown Editor` fieldtype: the canonical rich text editor in markdown capabilities (LC-52) WYSIWYG constrained to GFM-expressible features (no highlight/underline/color/alignment), serializing to and from a markdown string. Native falls back to markdown source editing.",
32
32
  },
33
33
  },
34
34
  },
@@ -0,0 +1,292 @@
1
+ /**
2
+ * MarkdownEditorField (NATIVE fallback) — markdown source pane with a live
3
+ * rendered preview (house `@multiplatform.one/markdown` renderer).
4
+ *
5
+ * The web tier renders the canonical rich text editor constrained to
6
+ * markdown capabilities (LC-52 MARKDOWN-EXPRESSIBLE-EDITING). On native the
7
+ * 10Tap WebView cannot express that constraint honestly tonight (extension
8
+ * gating + markdown serialization live in the web bundle), so this tier
9
+ * keeps plain markdown source editing — documented fallback, not a parallel
10
+ * WYSIWYG implementation.
11
+ *
12
+ * Full field contract: three-branch render (skeleton → standalone →
13
+ * form-integrated), canonical `onChange(value)` (LC-14), FieldLayout slot
14
+ * order, chromeless inside table cells (useIsInTableCell).
15
+ */
16
+
17
+ import {
18
+ Field,
19
+ FieldLayout,
20
+ Skeleton,
21
+ TextArea,
22
+ formCommonColors,
23
+ getFieldError,
24
+ mergeFieldHandler,
25
+ useFormField,
26
+ useIsInTableCell,
27
+ useResolvedValidators,
28
+ type AnyFormApi,
29
+ } from "@multiplatform.one/forms";
30
+ import { useEffect, useState, type ComponentType, type ReactNode } from "react";
31
+ import { useTranslation } from "react-i18next";
32
+ import type { FontSizeTokens, LabelProps, SizeTokens } from "tamagui";
33
+ import { Paragraph, YStack } from "tamagui";
34
+ import { useResolvedKnobs } from "@multiplatform.one/theme";
35
+ import { EditorPreviewPanes, PreviewSurface } from "../shared/EditorPreviewPanes";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Preview (lazy house Markdown renderer)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ type MarkdownComponent = ComponentType<{ children?: string }>;
42
+
43
+ // Module-level cache so the renderer loads once per session, not per field.
44
+ let markdownRendererCache: MarkdownComponent | null = null;
45
+
46
+ /**
47
+ * The house Markdown renderer loads on demand the first time a preview pane
48
+ * renders (same optional-dependency pattern as the components shiki
49
+ * Highlighter): `@multiplatform.one/markdown` reaches consumers transitively
50
+ * via `@multiplatform.one/rich-text`, and a failed import degrades to a
51
+ * plain-text preview instead of crashing the field.
52
+ */
53
+ function useMarkdownRenderer(enabled: boolean): MarkdownComponent | null {
54
+ const [renderer, setRenderer] = useState<MarkdownComponent | null>(markdownRendererCache);
55
+
56
+ useEffect(() => {
57
+ if (!enabled || renderer) return;
58
+ let cancelled = false;
59
+ import("@multiplatform.one/markdown")
60
+ .then((mod) => {
61
+ markdownRendererCache = mod.Markdown as MarkdownComponent;
62
+ if (!cancelled) setRenderer(() => markdownRendererCache);
63
+ })
64
+ .catch(() => {
65
+ // Renderer unavailable — the plain-text fallback below stays up.
66
+ });
67
+ return () => {
68
+ cancelled = true;
69
+ };
70
+ }, [enabled, renderer]);
71
+
72
+ return renderer;
73
+ }
74
+
75
+ interface MarkdownPreviewProps {
76
+ value: string;
77
+ compact?: boolean;
78
+ }
79
+
80
+ function MarkdownPreview({ value, compact }: MarkdownPreviewProps) {
81
+ const { t } = useTranslation();
82
+ const { knobProps } = useResolvedKnobs({ compact });
83
+ const Renderer = useMarkdownRenderer(!!value);
84
+ // Content type scale follows the size knob (same idiom as CodeBlock).
85
+ const textSize = knobProps.sizeToken as FontSizeTokens;
86
+
87
+ return (
88
+ <PreviewSurface compact={compact}>
89
+ {!value ? (
90
+ <Paragraph color={formCommonColors.muted} size={textSize}>
91
+ {t("Nothing to preview")}
92
+ </Paragraph>
93
+ ) : Renderer ? (
94
+ <Renderer>{value}</Renderer>
95
+ ) : (
96
+ // Loading / renderer unavailable: honest plain-text fallback.
97
+ <Paragraph size={textSize} whiteSpace="pre-wrap">
98
+ {value}
99
+ </Paragraph>
100
+ )}
101
+ </PreviewSurface>
102
+ );
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // MarkdownEditorField
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export interface MarkdownEditorFieldProps {
110
+ name?: string;
111
+ form?: AnyFormApi;
112
+ validators?: Record<string, unknown>;
113
+ label?: ReactNode;
114
+ labelProps?: Omit<LabelProps, "htmlFor" | "ref">;
115
+ helperText?: string;
116
+ error?: string | boolean;
117
+ required?: boolean;
118
+ disabled?: boolean;
119
+ readOnly?: boolean;
120
+ skeleton?: boolean;
121
+ compact?: boolean;
122
+ size?: SizeTokens;
123
+ id?: string;
124
+ value?: string;
125
+ defaultValue?: string;
126
+ /** Canonical change contract (LC-14): emits the markdown source string. */
127
+ onChange?: (value: string) => void;
128
+ onBlur?: (...args: any[]) => void;
129
+ placeholder?: string;
130
+ /** Minimum visible source rows (default 6). */
131
+ minRows?: number;
132
+ /** Maximum source rows before the textarea scrolls (default 16). */
133
+ maxRows?: number;
134
+ "aria-label"?: string;
135
+ }
136
+
137
+ export function MarkdownEditorField({
138
+ name,
139
+ form: formProp,
140
+ validators,
141
+ label,
142
+ labelProps,
143
+ helperText,
144
+ error,
145
+ required,
146
+ disabled,
147
+ readOnly,
148
+ skeleton,
149
+ compact,
150
+ size,
151
+ id: idProp,
152
+ value,
153
+ defaultValue,
154
+ onChange,
155
+ onBlur,
156
+ placeholder,
157
+ minRows = 6,
158
+ maxRows = 16,
159
+ "aria-label": ariaLabel,
160
+ }: MarkdownEditorFieldProps) {
161
+ const { t } = useTranslation();
162
+ const { resolvedForm, knobProps, id } = useFormField({ form: formProp, id: idProp, compact });
163
+ const resolvedValidators = useResolvedValidators(required, validators, label, name);
164
+ const inTableCell = useIsInTableCell();
165
+
166
+ // Standalone/uncontrolled mirror so the live preview always has the current
167
+ // source (the form branch reads field state instead).
168
+ const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? "");
169
+ useEffect(() => {
170
+ if (value !== undefined) setInternalValue(value);
171
+ }, [value]);
172
+
173
+ const accessibleName =
174
+ ariaLabel ?? (typeof label === "string" ? label : undefined) ?? placeholder;
175
+
176
+ if (skeleton) {
177
+ return (
178
+ <YStack {...knobProps.gap} width="100%">
179
+ {label && <Skeleton variant="text" width="30%" height={14} />}
180
+ <Skeleton variant="rounded" width="100%" height={compact ? 120 : 200} />
181
+ </YStack>
182
+ );
183
+ }
184
+
185
+ const renderSource = (
186
+ textValue: string,
187
+ handleChange?: (text: string) => void,
188
+ hasError?: boolean,
189
+ ) => (
190
+ <TextArea
191
+ id={id}
192
+ value={textValue}
193
+ onChangeText={(text) => {
194
+ if (disabled || readOnly) return;
195
+ handleChange?.(text);
196
+ onChange?.(text);
197
+ }}
198
+ placeholder={placeholder}
199
+ disabled={disabled}
200
+ readOnly={readOnly}
201
+ required={required}
202
+ error={hasError ? true : undefined}
203
+ compact={compact}
204
+ size={size}
205
+ minRows={minRows}
206
+ maxRows={maxRows}
207
+ textAreaProps={{
208
+ fontFamily: "$mono",
209
+ autoCapitalize: "none",
210
+ autoCorrect: false,
211
+ spellCheck: false,
212
+ ...(accessibleName ? { "aria-label": accessibleName } : {}),
213
+ }}
214
+ />
215
+ );
216
+
217
+ // Table cells get the chromeless source pane only (no tabs/preview chrome);
218
+ // InputParts inside the forms TextArea detects the cell context itself.
219
+ const renderControl = (
220
+ textValue: string,
221
+ handleChange?: (text: string) => void,
222
+ hasError?: boolean,
223
+ ) => {
224
+ if (inTableCell) return renderSource(textValue, handleChange, hasError);
225
+ return (
226
+ <EditorPreviewPanes
227
+ editor={renderSource(textValue, handleChange, hasError)}
228
+ preview={<MarkdownPreview value={textValue} compact={compact} />}
229
+ editTabLabel={t("Write")}
230
+ previewTabLabel={t("Preview")}
231
+ compact={compact}
232
+ defaultPreview={!!readOnly}
233
+ />
234
+ );
235
+ };
236
+
237
+ if (!resolvedForm || !name) {
238
+ if (inTableCell) return renderControl(internalValue, setInternalValue, !!error);
239
+ return (
240
+ <FieldLayout
241
+ id={id}
242
+ label={label}
243
+ labelProps={labelProps}
244
+ error={error}
245
+ helperText={helperText}
246
+ required={required}
247
+ disabled={disabled}
248
+ size={size}
249
+ knobProps={knobProps}
250
+ onBlur={onBlur}
251
+ aria-label={ariaLabel}
252
+ placeholder={placeholder}
253
+ >
254
+ {renderControl(internalValue, setInternalValue, !!error)}
255
+ </FieldLayout>
256
+ );
257
+ }
258
+
259
+ return (
260
+ <Field
261
+ form={resolvedForm}
262
+ name={name}
263
+ defaultValue={defaultValue}
264
+ validators={resolvedValidators}
265
+ >
266
+ {(field) => {
267
+ const resolvedError = getFieldError(field, error);
268
+ const fieldValue = typeof field.state.value === "string" ? field.state.value : "";
269
+ const handleChange = (text: string) => field.handleChange(text as any);
270
+ if (inTableCell) return renderControl(fieldValue, handleChange, !!resolvedError);
271
+ return (
272
+ <FieldLayout
273
+ id={id}
274
+ label={label}
275
+ labelProps={labelProps}
276
+ error={resolvedError}
277
+ helperText={helperText}
278
+ required={required}
279
+ disabled={disabled}
280
+ size={size}
281
+ knobProps={knobProps}
282
+ onBlur={mergeFieldHandler(field, "handleBlur", onBlur)}
283
+ aria-label={ariaLabel}
284
+ placeholder={placeholder}
285
+ >
286
+ {renderControl(fieldValue, handleChange, !!resolvedError)}
287
+ </FieldLayout>
288
+ );
289
+ }}
290
+ </Field>
291
+ );
292
+ }
@@ -1,20 +1,24 @@
1
1
  /**
2
2
  * MarkdownEditorField — dedicated renderer for the Frappe `Markdown Editor`
3
- * fieldtype: a monospace markdown source pane with a live rendered preview
4
- * (house `@multiplatform.one/markdown` renderer). Side-by-side at container
5
- * widths the house md breakpoint, Write/Preview tabs below it.
3
+ * fieldtype: the canonical house rich text editor constrained to markdown
4
+ * capabilities (LC-52 MARKDOWN-EXPRESSIBLE-EDITING). WYSIWYG editing whose
5
+ * surface is exactly what GFM can express no highlight, underline, text
6
+ * color, or alignment — with `value`/`onChange` staying markdown strings
7
+ * (Frappe stores markdown text for this fieldtype).
6
8
  *
7
9
  * Full field contract: three-branch render (skeleton → standalone →
8
10
  * form-integrated), canonical `onChange(value)` (LC-14), FieldLayout slot
9
11
  * order, chromeless inside table cells (useIsInTableCell).
12
+ *
13
+ * Native renders the markdown source + preview fallback (see
14
+ * `index.native.tsx`) — 10Tap cannot honestly express the capability
15
+ * constraint, and a lookalike would violate the honest-gating posture.
10
16
  */
11
17
 
12
18
  import {
13
19
  Field,
14
20
  FieldLayout,
15
21
  Skeleton,
16
- TextArea,
17
- formCommonColors,
18
22
  getFieldError,
19
23
  mergeFieldHandler,
20
24
  useFormField,
@@ -22,84 +26,13 @@ import {
22
26
  useResolvedValidators,
23
27
  type AnyFormApi,
24
28
  } from "@multiplatform.one/forms";
25
- import { useEffect, useState, type ComponentType, type ReactNode } from "react";
26
- import { useTranslation } from "react-i18next";
27
- import type { FontSizeTokens, LabelProps, SizeTokens } from "tamagui";
28
- import { Paragraph, YStack } from "tamagui";
29
- import { useResolvedKnobs } from "@multiplatform.one/theme";
30
- import { EditorPreviewPanes, PreviewSurface } from "../shared/EditorPreviewPanes";
31
-
32
- // ---------------------------------------------------------------------------
33
- // Preview (lazy house Markdown renderer)
34
- // ---------------------------------------------------------------------------
35
-
36
- type MarkdownComponent = ComponentType<{ children?: string }>;
37
-
38
- // Module-level cache so the renderer loads once per session, not per field.
39
- let markdownRendererCache: MarkdownComponent | null = null;
29
+ import { RichEditor, type RichEditorRef } from "@multiplatform.one/rich-text";
30
+ import { useEffect, useRef, useState, type ReactNode } from "react";
31
+ import type { LabelProps, SizeTokens } from "tamagui";
32
+ import { YStack } from "tamagui";
40
33
 
41
- /**
42
- * The house Markdown renderer loads on demand the first time a preview pane
43
- * renders (same optional-dependency pattern as the components shiki
44
- * Highlighter): `@multiplatform.one/markdown` reaches consumers transitively
45
- * via `@multiplatform.one/rich-text`, and a failed import degrades to a
46
- * plain-text preview instead of crashing the field.
47
- */
48
- function useMarkdownRenderer(enabled: boolean): MarkdownComponent | null {
49
- const [renderer, setRenderer] = useState<MarkdownComponent | null>(markdownRendererCache);
50
-
51
- useEffect(() => {
52
- if (!enabled || renderer) return;
53
- let cancelled = false;
54
- import("@multiplatform.one/markdown")
55
- .then((mod) => {
56
- markdownRendererCache = mod.Markdown as MarkdownComponent;
57
- if (!cancelled) setRenderer(() => markdownRendererCache);
58
- })
59
- .catch(() => {
60
- // Renderer unavailable — the plain-text fallback below stays up.
61
- });
62
- return () => {
63
- cancelled = true;
64
- };
65
- }, [enabled, renderer]);
66
-
67
- return renderer;
68
- }
69
-
70
- interface MarkdownPreviewProps {
71
- value: string;
72
- compact?: boolean;
73
- }
74
-
75
- function MarkdownPreview({ value, compact }: MarkdownPreviewProps) {
76
- const { t } = useTranslation();
77
- const { knobProps } = useResolvedKnobs({ compact });
78
- const Renderer = useMarkdownRenderer(!!value);
79
- // Content type scale follows the size knob (same idiom as CodeBlock).
80
- const textSize = knobProps.sizeToken as FontSizeTokens;
81
-
82
- return (
83
- <PreviewSurface compact={compact}>
84
- {!value ? (
85
- <Paragraph color={formCommonColors.muted} size={textSize}>
86
- {t("Nothing to preview")}
87
- </Paragraph>
88
- ) : Renderer ? (
89
- <Renderer>{value}</Renderer>
90
- ) : (
91
- // Loading / renderer unavailable: honest plain-text fallback.
92
- <Paragraph size={textSize} whiteSpace="pre-wrap">
93
- {value}
94
- </Paragraph>
95
- )}
96
- </PreviewSurface>
97
- );
98
- }
99
-
100
- // ---------------------------------------------------------------------------
101
- // MarkdownEditorField
102
- // ---------------------------------------------------------------------------
34
+ /** Approximate source-row height used to map the row props onto the editor */
35
+ const ROW_HEIGHT = 24;
103
36
 
104
37
  export interface MarkdownEditorFieldProps {
105
38
  name?: string;
@@ -122,9 +55,9 @@ export interface MarkdownEditorFieldProps {
122
55
  onChange?: (value: string) => void;
123
56
  onBlur?: (...args: any[]) => void;
124
57
  placeholder?: string;
125
- /** Minimum visible source rows (default 6). */
58
+ /** Minimum visible content rows (default 6). */
126
59
  minRows?: number;
127
- /** Maximum source rows before the textarea scrolls (default 16). */
60
+ /** Maximum content rows before the editor scrolls (default 16). */
128
61
  maxRows?: number;
129
62
  "aria-label"?: string;
130
63
  }
@@ -153,21 +86,28 @@ export function MarkdownEditorField({
153
86
  maxRows = 16,
154
87
  "aria-label": ariaLabel,
155
88
  }: MarkdownEditorFieldProps) {
156
- const { t } = useTranslation();
157
89
  const { resolvedForm, knobProps, id } = useFormField({ form: formProp, id: idProp, compact });
158
90
  const resolvedValidators = useResolvedValidators(required, validators, label, name);
159
91
  const inTableCell = useIsInTableCell();
160
92
 
161
- // Standalone/uncontrolled mirror so the live preview always has the current
162
- // source (the form branch reads field state instead).
93
+ const editorRef = useRef<RichEditorRef>(null);
94
+ // Last markdown the editor emitted (or was seeded with) — distinguishes
95
+ // echo renders from real external value changes so programmatic sync
96
+ // never clobbers the caret.
97
+ const lastEmittedRef = useRef<string>(value ?? defaultValue ?? "");
163
98
  const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? "");
99
+
100
+ // External (controlled) value change: push into the editor without
101
+ // emitting onChange back (setMarkdown is emitUpdate: false).
164
102
  useEffect(() => {
165
- if (value !== undefined) setInternalValue(value);
103
+ if (value === undefined) return;
104
+ setInternalValue(value);
105
+ if (value !== lastEmittedRef.current) {
106
+ lastEmittedRef.current = value;
107
+ editorRef.current?.setMarkdown(value);
108
+ }
166
109
  }, [value]);
167
110
 
168
- const accessibleName =
169
- ariaLabel ?? (typeof label === "string" ? label : undefined) ?? placeholder;
170
-
171
111
  if (skeleton) {
172
112
  return (
173
113
  <YStack {...knobProps.gap} width="100%">
@@ -177,60 +117,39 @@ export function MarkdownEditorField({
177
117
  );
178
118
  }
179
119
 
180
- const renderSource = (
181
- textValue: string,
182
- handleChange?: (text: string) => void,
120
+ const renderControl = (
121
+ markdownValue: string,
122
+ handleChange?: (markdown: string) => void,
123
+ handleFieldBlur?: () => void,
183
124
  hasError?: boolean,
184
125
  ) => (
185
- <TextArea
186
- id={id}
187
- value={textValue}
188
- onChangeText={(text) => {
126
+ <RichEditor
127
+ ref={editorRef}
128
+ capabilities="markdown"
129
+ initialContent={markdownValue}
130
+ onChange={(markdown) => {
189
131
  if (disabled || readOnly) return;
190
- handleChange?.(text);
191
- onChange?.(text);
132
+ lastEmittedRef.current = markdown;
133
+ handleChange?.(markdown);
134
+ onChange?.(markdown);
192
135
  }}
136
+ onBlur={handleFieldBlur}
193
137
  placeholder={placeholder}
194
- disabled={disabled}
195
- readOnly={readOnly}
196
- required={required}
197
- error={hasError ? true : undefined}
198
- compact={compact}
138
+ editable={!disabled && !readOnly}
139
+ minHeight={minRows * ROW_HEIGHT}
140
+ maxHeight={maxRows * ROW_HEIGHT}
141
+ hasError={hasError}
199
142
  size={size}
200
- minRows={minRows}
201
- maxRows={maxRows}
202
- textAreaProps={{
203
- fontFamily: "$mono",
204
- autoCapitalize: "none",
205
- autoCorrect: false,
206
- spellCheck: false,
207
- ...(accessibleName ? { "aria-label": accessibleName } : {}),
208
- }}
143
+ compact={compact || inTableCell}
144
+ // Table cells stay chromeless: no fixed toolbar, bubble on selection.
145
+ showToolbar={!inTableCell}
146
+ showBubbleToolbar={inTableCell}
209
147
  />
210
148
  );
211
149
 
212
- // Table cells get the chromeless source pane only (no tabs/preview chrome);
213
- // InputParts inside the forms TextArea detects the cell context itself.
214
- const renderControl = (
215
- textValue: string,
216
- handleChange?: (text: string) => void,
217
- hasError?: boolean,
218
- ) => {
219
- if (inTableCell) return renderSource(textValue, handleChange, hasError);
220
- return (
221
- <EditorPreviewPanes
222
- editor={renderSource(textValue, handleChange, hasError)}
223
- preview={<MarkdownPreview value={textValue} compact={compact} />}
224
- editTabLabel={t("Write")}
225
- previewTabLabel={t("Preview")}
226
- compact={compact}
227
- defaultPreview={!!readOnly}
228
- />
229
- );
230
- };
231
-
232
150
  if (!resolvedForm || !name) {
233
- if (inTableCell) return renderControl(internalValue, setInternalValue, !!error);
151
+ const standaloneControl = renderControl(internalValue, setInternalValue, onBlur, !!error);
152
+ if (inTableCell) return standaloneControl;
234
153
  return (
235
154
  <FieldLayout
236
155
  id={id}
@@ -246,7 +165,7 @@ export function MarkdownEditorField({
246
165
  aria-label={ariaLabel}
247
166
  placeholder={placeholder}
248
167
  >
249
- {renderControl(internalValue, setInternalValue, !!error)}
168
+ {standaloneControl}
250
169
  </FieldLayout>
251
170
  );
252
171
  }
@@ -261,8 +180,14 @@ export function MarkdownEditorField({
261
180
  {(field) => {
262
181
  const resolvedError = getFieldError(field, error);
263
182
  const fieldValue = typeof field.state.value === "string" ? field.state.value : "";
264
- const handleChange = (text: string) => field.handleChange(text as any);
265
- if (inTableCell) return renderControl(fieldValue, handleChange, !!resolvedError);
183
+ const handleChange = (markdown: string) => field.handleChange(markdown as any);
184
+ const formControl = renderControl(
185
+ fieldValue,
186
+ handleChange,
187
+ () => field.handleBlur(),
188
+ !!resolvedError,
189
+ );
190
+ if (inTableCell) return formControl;
266
191
  return (
267
192
  <FieldLayout
268
193
  id={id}
@@ -278,7 +203,7 @@ export function MarkdownEditorField({
278
203
  aria-label={ariaLabel}
279
204
  placeholder={placeholder}
280
205
  >
281
- {renderControl(fieldValue, handleChange, !!resolvedError)}
206
+ {formControl}
282
207
  </FieldLayout>
283
208
  );
284
209
  }}