@multiplatform.one/rich-text 7.6.3 → 7.7.1
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/RichEditor.native.js +20 -0
- package/dist/cjs/RichEditor.native.js.map +1 -1
- package/dist/cjs/editor/TentapEditor.native.js +18 -0
- package/dist/cjs/editor/TentapEditor.native.js.map +1 -1
- package/dist/esm/RichEditor.native.js +21 -1
- package/dist/esm/RichEditor.native.js.map +1 -1
- package/dist/esm/editor/TentapEditor.native.js +18 -0
- package/dist/esm/editor/TentapEditor.native.js.map +1 -1
- package/dist/jsx/RichEditor.native.js +20 -0
- package/dist/jsx/RichEditor.native.js.map +1 -1
- package/dist/jsx/editor/TentapEditor.native.js +18 -0
- package/dist/jsx/editor/TentapEditor.native.js.map +1 -1
- package/package.json +8 -9
- package/src/RichEditor.native.tsx +19 -1
- package/src/RichEditor.parts.stories.tsx +139 -0
- package/src/editor/SlashCommands.spec.tsx +210 -0
- package/src/editor/TentapEditor.native.tsx +35 -0
- package/src/richEditorPartsStories.spec.tsx +150 -0
- package/src/toolbar/CodeBlockBubble.spec.tsx +219 -0
- package/src/toolbar/FixedToolbar.stories.tsx +224 -0
- package/src/toolbar/ImageBubble.spec.tsx +140 -0
- package/src/toolbar/LinkBubble.spec.tsx +159 -0
- package/src/toolbar/ResizeHandle.spec.tsx +118 -0
- package/src/toolbar/StatusBar.spec.tsx +100 -0
- package/src/toolbar/StatusBar.stories.tsx +146 -0
- package/src/toolbar/TableBubble.spec.tsx +189 -0
- package/src/toolbar/ToolbarButton.stories.tsx +193 -0
- package/src/toolbar/ToolbarGroup.stories.tsx +148 -0
- package/src/toolbar/fixedToolbarStories.spec.tsx +200 -0
- package/src/toolbar/statusBarStories.spec.tsx +86 -0
- package/src/toolbar/toolbarButtonStories.spec.tsx +122 -0
- package/src/toolbar/toolbarGroupStories.spec.tsx +108 -0
- package/types/RichEditor.native.d.ts.map +1 -1
- package/types/editor/TentapEditor.native.d.ts +4 -0
- package/types/editor/TentapEditor.native.d.ts.map +1 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fireEvent } from "@testing-library/react";
|
|
3
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
4
|
+
import { RichEditorContext } from "../context";
|
|
5
|
+
|
|
6
|
+
const bubble = vi.hoisted(() => ({ props: null as Record<string, any> | null }));
|
|
7
|
+
|
|
8
|
+
vi.mock("@tiptap/react/menus", () => ({
|
|
9
|
+
BubbleMenu: (props: any) => {
|
|
10
|
+
bubble.props = props;
|
|
11
|
+
return <div data-testid="bubble-menu">{props.children}</div>;
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
|
|
16
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
17
|
+
Button: (props: any) => (
|
|
18
|
+
<button role="button" aria-label={props["aria-label"]} onClick={props.onPress}>
|
|
19
|
+
{props.children}
|
|
20
|
+
</button>
|
|
21
|
+
),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("@multiplatform.one/components", async (importOriginal) => ({
|
|
25
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
26
|
+
Tooltip: ({ children }: any) => children,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const { ImageBubble } = await import("./ImageBubble");
|
|
30
|
+
|
|
31
|
+
/** Records every link in the command chain so an assertion can read the whole call. */
|
|
32
|
+
function makeEditor(attrs: Record<string, unknown> = {}, active = true) {
|
|
33
|
+
const calls: Array<[string, ...unknown[]]> = [];
|
|
34
|
+
const chain: any = new Proxy(
|
|
35
|
+
{},
|
|
36
|
+
{
|
|
37
|
+
get:
|
|
38
|
+
(_target, prop: string) =>
|
|
39
|
+
(...args: unknown[]) => {
|
|
40
|
+
calls.push([prop, ...args]);
|
|
41
|
+
return chain;
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
const editor = {
|
|
46
|
+
chain: () => chain,
|
|
47
|
+
getAttributes: (name: string) => (name === "image" ? attrs : {}),
|
|
48
|
+
isActive: (name: string) => name === "image" && active,
|
|
49
|
+
} as any;
|
|
50
|
+
return { editor, calls, names: () => calls.map((c) => c[0]) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function render(editor: any) {
|
|
54
|
+
return renderWithProviders(
|
|
55
|
+
<RichEditorContext.Provider size="$true" compact={false} disabled={false}>
|
|
56
|
+
<ImageBubble editor={editor} />
|
|
57
|
+
</RichEditorContext.Provider>,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function press(container: HTMLElement, label: string) {
|
|
62
|
+
const el = container.querySelector(`[aria-label="${label}"]`);
|
|
63
|
+
if (!el) throw new Error(`no control labelled "${label}"`);
|
|
64
|
+
fireEvent.click(el);
|
|
65
|
+
return el;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("ImageBubble", () => {
|
|
69
|
+
it("renders nothing at all without an editor", () => {
|
|
70
|
+
const { container } = render(null);
|
|
71
|
+
expect(container.textContent).toBe("");
|
|
72
|
+
expect(container.querySelector('[data-testid="bubble-menu"]')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("shows itself only while an image node is selected", () => {
|
|
76
|
+
const onImage = makeEditor({ src: "a.png" }, true);
|
|
77
|
+
render(onImage.editor);
|
|
78
|
+
expect(bubble.props?.shouldShow({ editor: onImage.editor })).toBe(true);
|
|
79
|
+
|
|
80
|
+
const offImage = makeEditor({}, false);
|
|
81
|
+
render(offImage.editor);
|
|
82
|
+
expect(bubble.props?.shouldShow({ editor: offImage.editor })).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("shows the image's alt text, and says so plainly when there is none", () => {
|
|
86
|
+
const withAlt = render(makeEditor({ alt: "A red bicycle" }).editor);
|
|
87
|
+
expect(withAlt.getByText("A red bicycle")).toBeTruthy();
|
|
88
|
+
|
|
89
|
+
const withoutAlt = render(makeEditor({ src: "a.png" }).editor);
|
|
90
|
+
expect(withoutAlt.getByText("No alt text")).toBeTruthy();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("prefills the editing field with the alt text already on the node", () => {
|
|
94
|
+
const { container, getByText } = render(makeEditor({ alt: "A red bicycle" }).editor);
|
|
95
|
+
press(container, "Edit alt text");
|
|
96
|
+
|
|
97
|
+
expect(getByText("Alt text")).toBeTruthy();
|
|
98
|
+
const input = container.querySelector("input") as HTMLInputElement;
|
|
99
|
+
expect(input.value).toBe("A red bicycle");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("writes the edited alt text back onto the image node and leaves editing", () => {
|
|
103
|
+
const { editor, calls, names } = makeEditor({ alt: "old" });
|
|
104
|
+
const { container, queryByText } = render(editor);
|
|
105
|
+
|
|
106
|
+
press(container, "Edit alt text");
|
|
107
|
+
fireEvent.change(container.querySelector("input") as HTMLInputElement, {
|
|
108
|
+
target: { value: "new alt" },
|
|
109
|
+
});
|
|
110
|
+
press(container, "Save alt text");
|
|
111
|
+
|
|
112
|
+
expect(names()).toEqual(["focus", "updateAttributes", "run"]);
|
|
113
|
+
expect(calls[1]).toEqual(["updateAttributes", "image", { alt: "new alt" }]);
|
|
114
|
+
expect(queryByText("Alt text")).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("cancel leaves editing without touching the document", () => {
|
|
118
|
+
const { editor, calls } = makeEditor({ alt: "old" });
|
|
119
|
+
const { container, queryByText, getByText } = render(editor);
|
|
120
|
+
|
|
121
|
+
press(container, "Edit alt text");
|
|
122
|
+
fireEvent.change(container.querySelector("input") as HTMLInputElement, {
|
|
123
|
+
target: { value: "discarded" },
|
|
124
|
+
});
|
|
125
|
+
press(container, "Cancel");
|
|
126
|
+
|
|
127
|
+
expect(calls).toEqual([]);
|
|
128
|
+
expect(queryByText("Alt text")).toBeNull();
|
|
129
|
+
expect(getByText("old")).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("delete removes the selected node rather than clearing its attributes", () => {
|
|
133
|
+
const { editor, calls, names } = makeEditor({ alt: "old" });
|
|
134
|
+
const { container } = render(editor);
|
|
135
|
+
|
|
136
|
+
press(container, "Delete image");
|
|
137
|
+
expect(names()).toEqual(["focus", "deleteSelection", "run"]);
|
|
138
|
+
expect(calls.some(([name]) => name === "updateAttributes")).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import { fireEvent } from "@testing-library/react";
|
|
3
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
4
|
+
import { RichEditorContext } from "../context";
|
|
5
|
+
|
|
6
|
+
const bubble = vi.hoisted(() => ({ props: null as Record<string, any> | null }));
|
|
7
|
+
|
|
8
|
+
vi.mock("@tiptap/react/menus", () => ({
|
|
9
|
+
BubbleMenu: (props: any) => {
|
|
10
|
+
bubble.props = props;
|
|
11
|
+
return <div data-testid="bubble-menu">{props.children}</div>;
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
|
|
16
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
17
|
+
Button: (props: any) => (
|
|
18
|
+
<button role="button" aria-label={props["aria-label"]} onClick={props.onPress}>
|
|
19
|
+
{props.children}
|
|
20
|
+
</button>
|
|
21
|
+
),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("@multiplatform.one/components", async (importOriginal) => ({
|
|
25
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
26
|
+
Tooltip: ({ children }: any) => children,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const { LinkBubble } = await import("./LinkBubble");
|
|
30
|
+
|
|
31
|
+
function makeEditor(attrs: Record<string, unknown> = {}, active = true) {
|
|
32
|
+
const calls: Array<[string, ...unknown[]]> = [];
|
|
33
|
+
const chain: any = new Proxy(
|
|
34
|
+
{},
|
|
35
|
+
{
|
|
36
|
+
get:
|
|
37
|
+
(_target, prop: string) =>
|
|
38
|
+
(...args: unknown[]) => {
|
|
39
|
+
calls.push([prop, ...args]);
|
|
40
|
+
return chain;
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
const editor = {
|
|
45
|
+
chain: () => chain,
|
|
46
|
+
getAttributes: (name: string) => (name === "link" ? attrs : {}),
|
|
47
|
+
isActive: (name: string) => name === "link" && active,
|
|
48
|
+
} as any;
|
|
49
|
+
return { editor, calls, names: () => calls.map((c) => c[0]) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function render(editor: any) {
|
|
53
|
+
return renderWithProviders(
|
|
54
|
+
<RichEditorContext.Provider size="$true" compact={false} disabled={false}>
|
|
55
|
+
<LinkBubble editor={editor} />
|
|
56
|
+
</RichEditorContext.Provider>,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function press(container: HTMLElement, label: string) {
|
|
61
|
+
const el = container.querySelector(`[aria-label="${label}"]`);
|
|
62
|
+
if (!el) throw new Error(`no control labelled "${label}"`);
|
|
63
|
+
fireEvent.click(el);
|
|
64
|
+
return el;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("LinkBubble", () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.restoreAllMocks();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("renders nothing at all without an editor", () => {
|
|
73
|
+
const { container } = render(null);
|
|
74
|
+
expect(container.textContent).toBe("");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("shows itself only while the caret is on a link", () => {
|
|
78
|
+
const onLink = makeEditor({ href: "https://example.com" }, true);
|
|
79
|
+
render(onLink.editor);
|
|
80
|
+
expect(bubble.props?.shouldShow({ editor: onLink.editor })).toBe(true);
|
|
81
|
+
|
|
82
|
+
const offLink = makeEditor({}, false);
|
|
83
|
+
render(offLink.editor);
|
|
84
|
+
expect(bubble.props?.shouldShow({ editor: offLink.editor })).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("shows the href, and says so plainly when the mark carries none", () => {
|
|
88
|
+
const withHref = render(makeEditor({ href: "https://example.com/a" }).editor);
|
|
89
|
+
expect(withHref.getByText("https://example.com/a")).toBeTruthy();
|
|
90
|
+
|
|
91
|
+
const withoutHref = render(makeEditor({}).editor);
|
|
92
|
+
expect(withoutHref.getByText("No URL")).toBeTruthy();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("opens the href in a new tab with noopener, from both the label and the button", () => {
|
|
96
|
+
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
97
|
+
const { container, getByText } = render(makeEditor({ href: "https://example.com" }).editor);
|
|
98
|
+
|
|
99
|
+
fireEvent.click(getByText("https://example.com"));
|
|
100
|
+
press(container, "Open link");
|
|
101
|
+
|
|
102
|
+
expect(open).toHaveBeenCalledTimes(2);
|
|
103
|
+
expect(open).toHaveBeenCalledWith("https://example.com", "_blank", "noopener,noreferrer");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does not open a window when there is no href to open", () => {
|
|
107
|
+
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
108
|
+
const { container } = render(makeEditor({}).editor);
|
|
109
|
+
|
|
110
|
+
press(container, "Open link");
|
|
111
|
+
expect(open).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("prefills the edit field with the current href", () => {
|
|
115
|
+
const { container } = render(makeEditor({ href: "https://example.com" }).editor);
|
|
116
|
+
press(container, "Edit link");
|
|
117
|
+
expect((container.querySelector("input") as HTMLInputElement).value).toBe(
|
|
118
|
+
"https://example.com",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("saves over the whole mark range, with the value trimmed", () => {
|
|
123
|
+
const { editor, calls, names } = makeEditor({ href: "https://old.example" });
|
|
124
|
+
const { container } = render(editor);
|
|
125
|
+
|
|
126
|
+
press(container, "Edit link");
|
|
127
|
+
fireEvent.change(container.querySelector("input") as HTMLInputElement, {
|
|
128
|
+
target: { value: " https://new.example " },
|
|
129
|
+
});
|
|
130
|
+
press(container, "Save link");
|
|
131
|
+
|
|
132
|
+
expect(names()).toEqual(["focus", "extendMarkRange", "setLink", "run"]);
|
|
133
|
+
expect(calls[1]).toEqual(["extendMarkRange", "link"]);
|
|
134
|
+
expect(calls[2]).toEqual(["setLink", { href: "https://new.example" }]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("refuses to save a blank href, and still closes the editor", () => {
|
|
138
|
+
const { editor, calls } = makeEditor({ href: "https://old.example" });
|
|
139
|
+
const { container, getByText } = render(editor);
|
|
140
|
+
|
|
141
|
+
press(container, "Edit link");
|
|
142
|
+
fireEvent.change(container.querySelector("input") as HTMLInputElement, {
|
|
143
|
+
target: { value: " " },
|
|
144
|
+
});
|
|
145
|
+
press(container, "Save link");
|
|
146
|
+
|
|
147
|
+
expect(calls).toEqual([]);
|
|
148
|
+
expect(getByText("https://old.example")).toBeTruthy();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("unlinks over the whole mark range rather than the selection alone", () => {
|
|
152
|
+
const { editor, calls, names } = makeEditor({ href: "https://example.com" });
|
|
153
|
+
const { container } = render(editor);
|
|
154
|
+
|
|
155
|
+
press(container, "Remove link");
|
|
156
|
+
expect(names()).toEqual(["focus", "extendMarkRange", "unsetLink", "run"]);
|
|
157
|
+
expect(calls[1]).toEqual(["extendMarkRange", "link"]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
3
|
+
import { fireEvent } from "@testing-library/react";
|
|
4
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
5
|
+
import { ResizeHandle } from "./ResizeHandle";
|
|
6
|
+
|
|
7
|
+
/** The handle drives a real element, so give it one with a measurable height. */
|
|
8
|
+
function makeContainer(height: number) {
|
|
9
|
+
const el = document.createElement("div");
|
|
10
|
+
el.getBoundingClientRect = () => ({ height }) as DOMRect;
|
|
11
|
+
document.body.appendChild(el);
|
|
12
|
+
const ref = createRef<HTMLElement>();
|
|
13
|
+
(ref as { current: HTMLElement | null }).current = el;
|
|
14
|
+
return { el, ref };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function grab(container: HTMLElement) {
|
|
18
|
+
const handle = container.querySelector('[role="separator"]');
|
|
19
|
+
if (!handle) throw new Error("resize handle did not render");
|
|
20
|
+
return handle;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function drag(handle: Element, fromY: number, toY: number) {
|
|
24
|
+
fireEvent.mouseDown(handle, { clientY: fromY });
|
|
25
|
+
document.dispatchEvent(new MouseEvent("mousemove", { clientY: toY }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("ResizeHandle", () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
document.body.innerHTML = "";
|
|
31
|
+
document.body.style.cursor = "";
|
|
32
|
+
document.body.style.userSelect = "";
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("exposes itself to assistive tech as a labelled separator", () => {
|
|
36
|
+
const { ref } = makeContainer(300);
|
|
37
|
+
const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
|
|
38
|
+
const handle = grab(container);
|
|
39
|
+
expect(handle.getAttribute("aria-label")).toBe("Resize editor");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("adds the drag delta to the height the container had when the drag started", () => {
|
|
43
|
+
const { el, ref } = makeContainer(300);
|
|
44
|
+
const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
|
|
45
|
+
|
|
46
|
+
drag(grab(container), 100, 180);
|
|
47
|
+
expect(el.style.height).toBe("380px");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("shrinks on an upward drag, and floors at minHeight (default 150)", () => {
|
|
51
|
+
const { el, ref } = makeContainer(300);
|
|
52
|
+
const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
|
|
53
|
+
const handle = grab(container);
|
|
54
|
+
|
|
55
|
+
drag(handle, 100, 60);
|
|
56
|
+
expect(el.style.height).toBe("260px");
|
|
57
|
+
|
|
58
|
+
document.dispatchEvent(new MouseEvent("mousemove", { clientY: -500 }));
|
|
59
|
+
expect(el.style.height).toBe("150px");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("honours an explicit minHeight over the default", () => {
|
|
63
|
+
const { el, ref } = makeContainer(300);
|
|
64
|
+
const { container } = renderWithProviders(<ResizeHandle containerRef={ref} minHeight={220} />);
|
|
65
|
+
|
|
66
|
+
drag(grab(container), 100, -900);
|
|
67
|
+
expect(el.style.height).toBe("220px");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("caps at maxHeight when one is given, and does not cap when it is omitted", () => {
|
|
71
|
+
const capped = makeContainer(300);
|
|
72
|
+
const { container: cappedDom } = renderWithProviders(
|
|
73
|
+
<ResizeHandle containerRef={capped.ref} maxHeight={400} />,
|
|
74
|
+
);
|
|
75
|
+
drag(grab(cappedDom), 100, 900);
|
|
76
|
+
expect(capped.el.style.height).toBe("400px");
|
|
77
|
+
|
|
78
|
+
document.dispatchEvent(new MouseEvent("mouseup"));
|
|
79
|
+
|
|
80
|
+
const free = makeContainer(300);
|
|
81
|
+
const { container: freeDom } = renderWithProviders(<ResizeHandle containerRef={free.ref} />);
|
|
82
|
+
drag(grab(freeDom), 100, 900);
|
|
83
|
+
expect(free.el.style.height).toBe("1100px");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("locks the page cursor and selection for the length of the drag only", () => {
|
|
87
|
+
const { ref } = makeContainer(300);
|
|
88
|
+
const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
|
|
89
|
+
|
|
90
|
+
fireEvent.mouseDown(grab(container), { clientY: 100 });
|
|
91
|
+
expect(document.body.style.cursor).toBe("ns-resize");
|
|
92
|
+
expect(document.body.style.userSelect).toBe("none");
|
|
93
|
+
|
|
94
|
+
document.dispatchEvent(new MouseEvent("mouseup"));
|
|
95
|
+
expect(document.body.style.cursor).toBe("");
|
|
96
|
+
expect(document.body.style.userSelect).toBe("");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("stops resizing after mouseup, so a later pointer move is inert", () => {
|
|
100
|
+
const { el, ref } = makeContainer(300);
|
|
101
|
+
const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
|
|
102
|
+
|
|
103
|
+
drag(grab(container), 100, 200);
|
|
104
|
+
expect(el.style.height).toBe("400px");
|
|
105
|
+
|
|
106
|
+
document.dispatchEvent(new MouseEvent("mouseup"));
|
|
107
|
+
document.dispatchEvent(new MouseEvent("mousemove", { clientY: 900 }));
|
|
108
|
+
expect(el.style.height).toBe("400px");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("is a no-op while the container ref is still empty", () => {
|
|
112
|
+
const ref = createRef<HTMLElement>();
|
|
113
|
+
const { container } = renderWithProviders(<ResizeHandle containerRef={ref} />);
|
|
114
|
+
|
|
115
|
+
fireEvent.mouseDown(grab(container), { clientY: 100 });
|
|
116
|
+
expect(document.body.style.cursor).toBe("");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fireEvent } from "@testing-library/react";
|
|
3
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
4
|
+
import { RichEditorContext } from "../context";
|
|
5
|
+
import { defaultEditorState } from "../types";
|
|
6
|
+
import type { EditorState } from "../types";
|
|
7
|
+
import { StatusBar } from "./StatusBar";
|
|
8
|
+
|
|
9
|
+
function withContext(ui: React.ReactElement) {
|
|
10
|
+
return (
|
|
11
|
+
<RichEditorContext.Provider size="$true" compact={false} disabled={false}>
|
|
12
|
+
{ui}
|
|
13
|
+
</RichEditorContext.Provider>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function stateWith(counts: Partial<EditorState>): EditorState {
|
|
18
|
+
return { ...defaultEditorState, ...counts };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("StatusBar", () => {
|
|
22
|
+
it("offers both editor modes as pressable labels", () => {
|
|
23
|
+
const { getByText } = renderWithProviders(
|
|
24
|
+
withContext(<StatusBar state={defaultEditorState} mode="richtext" onModeChange={() => {}} />),
|
|
25
|
+
);
|
|
26
|
+
expect(getByText("Rich text")).toBeTruthy();
|
|
27
|
+
expect(getByText("Markdown")).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reports the mode the user picked, not the mode it is already in", () => {
|
|
31
|
+
const onModeChange = vi.fn();
|
|
32
|
+
const { getByText } = renderWithProviders(
|
|
33
|
+
withContext(
|
|
34
|
+
<StatusBar state={defaultEditorState} mode="richtext" onModeChange={onModeChange} />,
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
fireEvent.click(getByText("Markdown"));
|
|
39
|
+
expect(onModeChange).toHaveBeenCalledWith("markdown");
|
|
40
|
+
|
|
41
|
+
fireEvent.click(getByText("Rich text"));
|
|
42
|
+
expect(onModeChange).toHaveBeenLastCalledWith("richtext");
|
|
43
|
+
expect(onModeChange).toHaveBeenCalledTimes(2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("marks the active mode, and swaps the marking when the mode changes", () => {
|
|
47
|
+
const { getByText, rerender } = renderWithProviders(
|
|
48
|
+
withContext(<StatusBar state={defaultEditorState} mode="richtext" onModeChange={() => {}} />),
|
|
49
|
+
);
|
|
50
|
+
// Tamagui compiles the weight/colour props to class tokens, so the
|
|
51
|
+
// rendered class list is where "active" is actually observable.
|
|
52
|
+
const classesOf = (label: string) => (getByText(label) as HTMLElement).className;
|
|
53
|
+
|
|
54
|
+
const activeClasses = classesOf("Rich text");
|
|
55
|
+
const idleClasses = classesOf("Markdown");
|
|
56
|
+
expect(activeClasses).not.toBe(idleClasses);
|
|
57
|
+
expect(activeClasses).toContain("_fow-600");
|
|
58
|
+
expect(idleClasses).toContain("_fow-400");
|
|
59
|
+
|
|
60
|
+
rerender(
|
|
61
|
+
withContext(<StatusBar state={defaultEditorState} mode="markdown" onModeChange={() => {}} />),
|
|
62
|
+
);
|
|
63
|
+
expect(classesOf("Markdown")).toBe(activeClasses);
|
|
64
|
+
expect(classesOf("Rich text")).toBe(idleClasses);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("singularises exactly at one word and one character", () => {
|
|
68
|
+
const { getByText } = renderWithProviders(
|
|
69
|
+
withContext(
|
|
70
|
+
<StatusBar
|
|
71
|
+
state={stateWith({ wordCount: 1, characterCount: 1 })}
|
|
72
|
+
mode="richtext"
|
|
73
|
+
onModeChange={() => {}}
|
|
74
|
+
/>,
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
expect(getByText("1 word")).toBeTruthy();
|
|
78
|
+
expect(getByText("1 character")).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("pluralises at zero as well as at many", () => {
|
|
82
|
+
const { getByText, rerender } = renderWithProviders(
|
|
83
|
+
withContext(<StatusBar state={defaultEditorState} mode="richtext" onModeChange={() => {}} />),
|
|
84
|
+
);
|
|
85
|
+
expect(getByText("0 words")).toBeTruthy();
|
|
86
|
+
expect(getByText("0 characters")).toBeTruthy();
|
|
87
|
+
|
|
88
|
+
rerender(
|
|
89
|
+
withContext(
|
|
90
|
+
<StatusBar
|
|
91
|
+
state={stateWith({ wordCount: 42, characterCount: 240 })}
|
|
92
|
+
mode="richtext"
|
|
93
|
+
onModeChange={() => {}}
|
|
94
|
+
/>,
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
expect(getByText("42 words")).toBeTruthy();
|
|
98
|
+
expect(getByText("240 characters")).toBeTruthy();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storybook stories for StatusBar — the editor footer: the rich-text /
|
|
3
|
+
* markdown mode toggle on the left, word and character counts on the right.
|
|
4
|
+
*
|
|
5
|
+
* Stories: Main, SingularCounts, MarkdownMode, Empty, InFrame
|
|
6
|
+
*
|
|
7
|
+
* The footer is a pure function of `state` and `mode`, so the controls drive
|
|
8
|
+
* the two counts and the active mode directly; pressing a mode label reports
|
|
9
|
+
* through `onModeChange` (actions panel) and flips the local mode so the
|
|
10
|
+
* marking swaps the way it does inside RichEditor.
|
|
11
|
+
*/
|
|
12
|
+
import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
|
|
13
|
+
import { action } from "@multiplatform.one/storybook";
|
|
14
|
+
import { useEffect, useState } from "react";
|
|
15
|
+
import { Paragraph, View, YStack } from "tamagui";
|
|
16
|
+
import { RichEditorContext } from "../context";
|
|
17
|
+
import { EditorFrame } from "../styled";
|
|
18
|
+
import { defaultEditorState } from "../types";
|
|
19
|
+
import type { EditorMode, EditorState } from "../types";
|
|
20
|
+
import { StatusBar } from "./StatusBar";
|
|
21
|
+
|
|
22
|
+
export interface StatusBarStoryArgs {
|
|
23
|
+
/** Word count shown on the right */
|
|
24
|
+
wordCount: number;
|
|
25
|
+
/** Character count shown on the right */
|
|
26
|
+
characterCount: number;
|
|
27
|
+
/** Which mode label is marked active */
|
|
28
|
+
mode: EditorMode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stateFor(args: StatusBarStoryArgs): EditorState {
|
|
32
|
+
return {
|
|
33
|
+
...defaultEditorState,
|
|
34
|
+
wordCount: args.wordCount,
|
|
35
|
+
characterCount: args.characterCount,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Mode is local so the toggle is live; the `mode` control seeds and resets it. */
|
|
40
|
+
function StatusBarSpecimen(args: StatusBarStoryArgs) {
|
|
41
|
+
const [mode, setMode] = useState<EditorMode>(args.mode);
|
|
42
|
+
useEffect(() => setMode(args.mode), [args.mode]);
|
|
43
|
+
return (
|
|
44
|
+
<StatusBar
|
|
45
|
+
state={stateFor(args)}
|
|
46
|
+
mode={mode}
|
|
47
|
+
onModeChange={(next) => {
|
|
48
|
+
action("onModeChange")(next);
|
|
49
|
+
setMode(next);
|
|
50
|
+
}}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const meta: Meta<StatusBarStoryArgs> = {
|
|
56
|
+
title: "Forms/RichTextEditor/StatusBar",
|
|
57
|
+
component: StatusBar as never,
|
|
58
|
+
parameters: {
|
|
59
|
+
layout: "padded",
|
|
60
|
+
},
|
|
61
|
+
argTypes: {
|
|
62
|
+
wordCount: { control: { type: "number", min: 0 } },
|
|
63
|
+
characterCount: { control: { type: "number", min: 0 } },
|
|
64
|
+
mode: { control: "radio", options: ["richtext", "markdown"] },
|
|
65
|
+
},
|
|
66
|
+
args: {
|
|
67
|
+
wordCount: 42,
|
|
68
|
+
characterCount: 240,
|
|
69
|
+
mode: "richtext",
|
|
70
|
+
},
|
|
71
|
+
decorators: [
|
|
72
|
+
(Story) => (
|
|
73
|
+
<RichEditorContext.Provider size="$true" compact={false} disabled={false}>
|
|
74
|
+
<Story />
|
|
75
|
+
</RichEditorContext.Provider>
|
|
76
|
+
),
|
|
77
|
+
],
|
|
78
|
+
render: (args) => (
|
|
79
|
+
<View maxWidth={520} borderWidth={1} borderColor="$color6" borderRadius="$3">
|
|
80
|
+
<StatusBarSpecimen {...args} />
|
|
81
|
+
</View>
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default meta;
|
|
86
|
+
type Story = StoryObj<StatusBarStoryArgs>;
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Main (with controls)
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export const Main: Story = {};
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// SingularCounts — the plural forms flip exactly at one
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export const SingularCounts: Story = {
|
|
99
|
+
args: {
|
|
100
|
+
wordCount: 1,
|
|
101
|
+
characterCount: 1,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// MarkdownMode — the raw-source toggle is the marked one
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export const MarkdownMode: Story = {
|
|
110
|
+
args: {
|
|
111
|
+
mode: "markdown",
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Empty — a fresh document pluralises at zero
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export const Empty: Story = {
|
|
120
|
+
args: {
|
|
121
|
+
wordCount: 0,
|
|
122
|
+
characterCount: 0,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// InFrame — where it sits: the foot of an EditorFrame, under the content
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export const InFrame: Story = {
|
|
131
|
+
name: "In frame",
|
|
132
|
+
args: {
|
|
133
|
+
wordCount: 12,
|
|
134
|
+
characterCount: 71,
|
|
135
|
+
},
|
|
136
|
+
render: (args) => (
|
|
137
|
+
<EditorFrame maxWidth={520} borderRadius="$3">
|
|
138
|
+
<YStack minHeight={120} padding="$3">
|
|
139
|
+
<Paragraph size="$3" color="$color11">
|
|
140
|
+
Twelve words of body copy stand in for the editor content here.
|
|
141
|
+
</Paragraph>
|
|
142
|
+
</YStack>
|
|
143
|
+
<StatusBarSpecimen {...args} />
|
|
144
|
+
</EditorFrame>
|
|
145
|
+
),
|
|
146
|
+
};
|