@multiplatform.one/rich-text 7.7.0 → 7.7.2
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 -8
- package/src/RichEditor.native.tsx +19 -1
- package/src/RichEditor.parts.stories.tsx +139 -0
- package/src/editor/TentapEditor.native.tsx +35 -0
- package/src/richEditorPartsStories.spec.tsx +150 -0
- package/src/toolbar/FixedToolbar.stories.tsx +224 -0
- package/src/toolbar/StatusBar.stories.tsx +146 -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,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storybook stories for ToolbarGroup — the toolbar's segmenting wrapper: it
|
|
3
|
+
* lays its buttons out in a row and, unless told otherwise, draws the vertical
|
|
4
|
+
* rule that separates one cluster of controls from the next.
|
|
5
|
+
*
|
|
6
|
+
* Stories: Main, NoSeparator, Hidden, TwoGroups
|
|
7
|
+
*
|
|
8
|
+
* The group has no state of its own, so the controls are its whole surface:
|
|
9
|
+
* `visible` decides whether anything renders at all, `showSeparator` decides
|
|
10
|
+
* whether the trailing rule does.
|
|
11
|
+
*/
|
|
12
|
+
import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
|
|
13
|
+
import { XStack } from "tamagui";
|
|
14
|
+
import { RichEditorContext } from "../context";
|
|
15
|
+
import { ToolbarFrame } from "../styled";
|
|
16
|
+
import { BoldIcon, ItalicIcon, RedoIcon, StrikeIcon, UndoIcon } from "./icons";
|
|
17
|
+
import { ToolbarButton } from "./ToolbarButton";
|
|
18
|
+
import { ToolbarGroup } from "./ToolbarGroup";
|
|
19
|
+
|
|
20
|
+
export interface ToolbarGroupStoryArgs {
|
|
21
|
+
/** Renders nothing at all when false — how the toolbar drops a section */
|
|
22
|
+
visible: boolean;
|
|
23
|
+
/** Draws the trailing vertical rule that ends the cluster */
|
|
24
|
+
showSeparator: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A history cluster: the pair the real toolbar opens with. */
|
|
28
|
+
function HistoryButtons() {
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<ToolbarButton onPress={() => {}} aria-label="Undo">
|
|
32
|
+
<UndoIcon />
|
|
33
|
+
</ToolbarButton>
|
|
34
|
+
<ToolbarButton onPress={() => {}} aria-label="Redo">
|
|
35
|
+
<RedoIcon />
|
|
36
|
+
</ToolbarButton>
|
|
37
|
+
</>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A formatting cluster: the group that follows history in the real toolbar. */
|
|
42
|
+
function FormattingButtons() {
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<ToolbarButton active onPress={() => {}} aria-label="Bold">
|
|
46
|
+
<BoldIcon>B</BoldIcon>
|
|
47
|
+
</ToolbarButton>
|
|
48
|
+
<ToolbarButton onPress={() => {}} aria-label="Italic">
|
|
49
|
+
<ItalicIcon>I</ItalicIcon>
|
|
50
|
+
</ToolbarButton>
|
|
51
|
+
<ToolbarButton onPress={() => {}} aria-label="Strikethrough">
|
|
52
|
+
<StrikeIcon>S</StrikeIcon>
|
|
53
|
+
</ToolbarButton>
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const meta: Meta<ToolbarGroupStoryArgs> = {
|
|
59
|
+
title: "Forms/RichTextEditor/ToolbarGroup",
|
|
60
|
+
component: ToolbarGroup as never,
|
|
61
|
+
parameters: {
|
|
62
|
+
layout: "padded",
|
|
63
|
+
},
|
|
64
|
+
argTypes: {
|
|
65
|
+
visible: { control: "boolean" },
|
|
66
|
+
showSeparator: { control: "boolean" },
|
|
67
|
+
},
|
|
68
|
+
args: {
|
|
69
|
+
visible: true,
|
|
70
|
+
showSeparator: true,
|
|
71
|
+
},
|
|
72
|
+
decorators: [
|
|
73
|
+
(Story) => (
|
|
74
|
+
<RichEditorContext.Provider size="$true" compact={false} disabled={false}>
|
|
75
|
+
<Story />
|
|
76
|
+
</RichEditorContext.Provider>
|
|
77
|
+
),
|
|
78
|
+
],
|
|
79
|
+
// The group only reads as a group inside the bar it segments, so every
|
|
80
|
+
// story renders it in a real ToolbarFrame with a neighbour after it.
|
|
81
|
+
render: (args) => (
|
|
82
|
+
<ToolbarFrame maxWidth={420}>
|
|
83
|
+
<XStack alignItems="center" gap="$1" paddingHorizontal="$2">
|
|
84
|
+
<ToolbarGroup visible={args.visible} showSeparator={args.showSeparator}>
|
|
85
|
+
<HistoryButtons />
|
|
86
|
+
</ToolbarGroup>
|
|
87
|
+
<ToolbarGroup showSeparator={false}>
|
|
88
|
+
<FormattingButtons />
|
|
89
|
+
</ToolbarGroup>
|
|
90
|
+
</XStack>
|
|
91
|
+
</ToolbarFrame>
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export default meta;
|
|
96
|
+
type Story = StoryObj<ToolbarGroupStoryArgs>;
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Main (with controls) — a group with its trailing rule
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
export const Main: Story = {};
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// NoSeparator — the last group in a bar ends without a rule
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export const NoSeparator: Story = {
|
|
109
|
+
name: "No separator",
|
|
110
|
+
args: {
|
|
111
|
+
showSeparator: false,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Hidden — visible={false} removes the buttons AND the rule
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export const Hidden: Story = {
|
|
120
|
+
args: {
|
|
121
|
+
visible: false,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// TwoGroups — the shape the toolbar is actually built from
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export const TwoGroups: Story = {
|
|
130
|
+
name: "Two groups",
|
|
131
|
+
render: () => (
|
|
132
|
+
<ToolbarFrame maxWidth={420}>
|
|
133
|
+
<XStack alignItems="center" gap="$1" paddingHorizontal="$2">
|
|
134
|
+
<ToolbarGroup>
|
|
135
|
+
<HistoryButtons />
|
|
136
|
+
</ToolbarGroup>
|
|
137
|
+
<ToolbarGroup>
|
|
138
|
+
<FormattingButtons />
|
|
139
|
+
</ToolbarGroup>
|
|
140
|
+
<ToolbarGroup showSeparator={false}>
|
|
141
|
+
<ToolbarButton onPress={() => {}} aria-label="Blockquote">
|
|
142
|
+
<BoldIcon>Q</BoldIcon>
|
|
143
|
+
</ToolbarButton>
|
|
144
|
+
</ToolbarGroup>
|
|
145
|
+
</XStack>
|
|
146
|
+
</ToolbarFrame>
|
|
147
|
+
),
|
|
148
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounts every FixedToolbar story with its args and asserts the visible
|
|
3
|
+
* contract each one exists to show, so a story that stops rendering what its
|
|
4
|
+
* name promises fails here before the gallery ships it.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { fireEvent } from "@testing-library/react";
|
|
8
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
9
|
+
|
|
10
|
+
// Stand-ins for the catalog Button and Tooltip, as in fixedToolbar.spec.tsx
|
|
11
|
+
// next to this one: the toolbar's contract is which controls exist and what
|
|
12
|
+
// they are labelled, which the swap leaves intact.
|
|
13
|
+
vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
|
|
14
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
15
|
+
Button: (props: any) => (
|
|
16
|
+
<button
|
|
17
|
+
role="button"
|
|
18
|
+
aria-label={props["aria-label"]}
|
|
19
|
+
aria-disabled={props["aria-disabled"]}
|
|
20
|
+
aria-pressed={props["aria-pressed"]}
|
|
21
|
+
onClick={props.onPress}
|
|
22
|
+
data-active={props.theme === "active" ? "true" : undefined}
|
|
23
|
+
>
|
|
24
|
+
{props.children}
|
|
25
|
+
</button>
|
|
26
|
+
),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("@multiplatform.one/components", async (importOriginal) => ({
|
|
30
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
31
|
+
Tooltip: ({ children }: any) => children,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// The LiveEditor story mounts a real editor. ProseMirror needs a browser
|
|
35
|
+
// layout engine happy-dom does not have, so the editor is stood in with the
|
|
36
|
+
// same mock shape richEditor.spec.tsx uses; what this spec asserts about that
|
|
37
|
+
// story is that the bar renders BESIDE an editor, not what the editor does.
|
|
38
|
+
const mockEditor = {
|
|
39
|
+
isActive: vi.fn().mockReturnValue(false),
|
|
40
|
+
getAttributes: vi.fn().mockReturnValue({}),
|
|
41
|
+
can: vi.fn().mockReturnValue({ undo: () => false, redo: () => false }),
|
|
42
|
+
getHTML: vi.fn().mockReturnValue("<p></p>"),
|
|
43
|
+
getJSON: vi.fn().mockReturnValue({ type: "doc", content: [] }),
|
|
44
|
+
chain: vi.fn().mockReturnValue({
|
|
45
|
+
focus: vi.fn().mockReturnValue({ toggleBold: vi.fn().mockReturnValue({ run: vi.fn() }) }),
|
|
46
|
+
}),
|
|
47
|
+
commands: { setContent: vi.fn(), focus: vi.fn(), blur: vi.fn() },
|
|
48
|
+
setEditable: vi.fn(),
|
|
49
|
+
storage: { mediaUpload: { handlers: {} }, suggestionChrome: { value: null } },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
vi.mock("@tiptap/react", () => ({
|
|
53
|
+
useEditor: vi.fn().mockReturnValue(mockEditor),
|
|
54
|
+
EditorContent: () => <div data-testid="editor-content">Editor</div>,
|
|
55
|
+
}));
|
|
56
|
+
vi.mock("@tiptap/react/menus", () => ({
|
|
57
|
+
BubbleMenu: ({ children }: any) => <div data-testid="bubble-menu">{children}</div>,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const storiesModule = await import("./FixedToolbar.stories");
|
|
61
|
+
const meta = storiesModule.default;
|
|
62
|
+
const { Main, Sections, MarkdownCapabilities, ErrorState, FullscreenToggle, LiveEditor } =
|
|
63
|
+
storiesModule;
|
|
64
|
+
type FixedToolbarStoryArgs = import("./FixedToolbar.stories").FixedToolbarStoryArgs;
|
|
65
|
+
|
|
66
|
+
type StoryLike = {
|
|
67
|
+
args?: Partial<FixedToolbarStoryArgs>;
|
|
68
|
+
render?: (args: FixedToolbarStoryArgs) => React.ReactElement;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Resolve a story the way Storybook does: meta args, story args, story render. */
|
|
72
|
+
function mount(story: StoryLike) {
|
|
73
|
+
const args = { ...(meta.args as FixedToolbarStoryArgs), ...story.args };
|
|
74
|
+
const render = story.render ?? (meta.render as StoryLike["render"]);
|
|
75
|
+
if (!render) throw new Error("story has no render");
|
|
76
|
+
const Story = () => render(args);
|
|
77
|
+
const Decorator = meta.decorators?.[0] as
|
|
78
|
+
| ((S: React.ComponentType) => React.ReactElement)
|
|
79
|
+
| undefined;
|
|
80
|
+
return renderWithProviders(Decorator ? Decorator(Story) : <Story />);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const stories = {
|
|
84
|
+
Main,
|
|
85
|
+
Sections,
|
|
86
|
+
MarkdownCapabilities,
|
|
87
|
+
ErrorState,
|
|
88
|
+
FullscreenToggle,
|
|
89
|
+
LiveEditor,
|
|
90
|
+
} as const;
|
|
91
|
+
|
|
92
|
+
const labelsIn = (container: HTMLElement) =>
|
|
93
|
+
Array.from(container.querySelectorAll("[role='button']")).map((b) =>
|
|
94
|
+
b.getAttribute("aria-label"),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
describe("FixedToolbar stories", () => {
|
|
98
|
+
it("nests under the editor title so the gallery buckets it with RichTextEditor", () => {
|
|
99
|
+
expect(meta.title).toBe("Forms/RichTextEditor/FixedToolbar");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it.each(Object.entries(stories))("%s mounts a labelled toolbar", (_name, story) => {
|
|
103
|
+
const { container } = mount(story);
|
|
104
|
+
const toolbar = container.querySelector("[role='toolbar']");
|
|
105
|
+
expect(toolbar).toBeTruthy();
|
|
106
|
+
expect(toolbar!.getAttribute("aria-label")).toBe("Formatting toolbar");
|
|
107
|
+
expect(labelsIn(container)).toContain("Bold");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("Main shows every primary group and keeps the overflow items in More", () => {
|
|
111
|
+
const { container } = mount(Main);
|
|
112
|
+
const labels = labelsIn(container);
|
|
113
|
+
for (const label of [
|
|
114
|
+
"Undo",
|
|
115
|
+
"Redo",
|
|
116
|
+
"Bold",
|
|
117
|
+
"Italic",
|
|
118
|
+
"Underline",
|
|
119
|
+
"Strikethrough",
|
|
120
|
+
"Text Color",
|
|
121
|
+
"Heading",
|
|
122
|
+
"Lists",
|
|
123
|
+
"Blockquote",
|
|
124
|
+
"Code Block",
|
|
125
|
+
"Link",
|
|
126
|
+
"Insert Image",
|
|
127
|
+
"More formatting options",
|
|
128
|
+
]) {
|
|
129
|
+
expect(labels).toContain(label);
|
|
130
|
+
}
|
|
131
|
+
expect(labels).not.toContain("Clear Formatting");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("Main washes Redo and leaves Undo available, matching its args", () => {
|
|
135
|
+
const { container } = mount(Main);
|
|
136
|
+
const at = (label: string) =>
|
|
137
|
+
container
|
|
138
|
+
.querySelector<HTMLElement>(`[aria-label="${label}"]`)!
|
|
139
|
+
.getAttribute("aria-disabled");
|
|
140
|
+
expect(at("Undo")).toBeNull();
|
|
141
|
+
expect(at("Redo")).toBe("true");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("Sections trims the bar to formatting and link", () => {
|
|
145
|
+
const { container } = mount(Sections);
|
|
146
|
+
const labels = labelsIn(container);
|
|
147
|
+
expect(labels).toContain("Bold");
|
|
148
|
+
expect(labels).toContain("Link");
|
|
149
|
+
expect(labels).not.toContain("Undo");
|
|
150
|
+
expect(labels).not.toContain("Heading");
|
|
151
|
+
expect(labels).not.toContain("Insert Image");
|
|
152
|
+
// isBold: true is what the story exists to show alongside the trim
|
|
153
|
+
expect(
|
|
154
|
+
container.querySelector<HTMLElement>('[aria-label="Bold"]')!.getAttribute("data-active"),
|
|
155
|
+
).toBe("true");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("MarkdownCapabilities drops the controls GFM cannot express", () => {
|
|
159
|
+
const { container } = mount(MarkdownCapabilities);
|
|
160
|
+
const labels = labelsIn(container);
|
|
161
|
+
expect(labels).toContain("Bold");
|
|
162
|
+
expect(labels).toContain("Link");
|
|
163
|
+
// Absent, not disabled: the commands are never registered.
|
|
164
|
+
expect(labels).not.toContain("Underline");
|
|
165
|
+
expect(labels).not.toContain("Text Color");
|
|
166
|
+
expect(labels).not.toContain("Highlight Color");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("ErrorState still renders the full bar (the error is the rule's colour)", () => {
|
|
170
|
+
const { container } = mount(ErrorState);
|
|
171
|
+
expect(labelsIn(container)).toContain("Bold");
|
|
172
|
+
expect(container.querySelector("[role='toolbar']")).toBeTruthy();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("FullscreenToggle adds the toggle and flips its pressed state", () => {
|
|
176
|
+
const { container, getByText } = mount(FullscreenToggle);
|
|
177
|
+
const toggle = container.querySelector<HTMLElement>('[aria-label="Fullscreen"]')!;
|
|
178
|
+
expect(toggle).toBeTruthy();
|
|
179
|
+
expect(toggle.getAttribute("aria-pressed")).toBe("false");
|
|
180
|
+
expect(getByText("Fullscreen: off")).toBeTruthy();
|
|
181
|
+
fireEvent.click(toggle);
|
|
182
|
+
expect(getByText("Fullscreen: on")).toBeTruthy();
|
|
183
|
+
expect(
|
|
184
|
+
container
|
|
185
|
+
.querySelector<HTMLElement>('[aria-label="Fullscreen"]')!
|
|
186
|
+
.getAttribute("aria-pressed"),
|
|
187
|
+
).toBe("true");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("Main has no fullscreen toggle, so the FullscreenToggle story earns its place", () => {
|
|
191
|
+
const { container } = mount(Main);
|
|
192
|
+
expect(container.querySelector('[aria-label="Fullscreen"]')).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("LiveEditor puts a standalone bar above a real editor", () => {
|
|
196
|
+
const { container } = mount(LiveEditor);
|
|
197
|
+
expect(container.querySelector("[role='toolbar']")).toBeTruthy();
|
|
198
|
+
expect(container.querySelector("[data-testid='editor-content']")).toBeTruthy();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounts every StatusBar story with its args and asserts the visible contract
|
|
3
|
+
* each one exists to show, so a story that stops rendering what its name
|
|
4
|
+
* promises fails here before the gallery ships it.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { fireEvent } from "@testing-library/react";
|
|
8
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
9
|
+
import meta, { Empty, InFrame, Main, MarkdownMode, SingularCounts } from "./StatusBar.stories";
|
|
10
|
+
import type { StatusBarStoryArgs } from "./StatusBar.stories";
|
|
11
|
+
|
|
12
|
+
type StoryLike = {
|
|
13
|
+
args?: Partial<StatusBarStoryArgs>;
|
|
14
|
+
render?: (args: StatusBarStoryArgs) => React.ReactElement;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Resolve a story the way Storybook does: meta args, story args, story render. */
|
|
18
|
+
function mount(story: StoryLike) {
|
|
19
|
+
const args = { ...(meta.args as StatusBarStoryArgs), ...story.args };
|
|
20
|
+
const render = story.render ?? (meta.render as StoryLike["render"]);
|
|
21
|
+
if (!render) throw new Error("story has no render");
|
|
22
|
+
const Story = () => render(args);
|
|
23
|
+
const Decorator = meta.decorators?.[0] as
|
|
24
|
+
| ((S: React.ComponentType) => React.ReactElement)
|
|
25
|
+
| undefined;
|
|
26
|
+
return renderWithProviders(Decorator ? Decorator(Story) : <Story />);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const stories = { Main, SingularCounts, MarkdownMode, Empty, InFrame } as const;
|
|
30
|
+
|
|
31
|
+
describe("StatusBar stories", () => {
|
|
32
|
+
it("nests under the editor title so the gallery buckets it with RichTextEditor", () => {
|
|
33
|
+
expect(meta.title).toBe("Forms/RichTextEditor/StatusBar");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each(Object.entries(stories))("%s mounts both mode labels", (_name, story) => {
|
|
37
|
+
const { getByText } = mount(story);
|
|
38
|
+
expect(getByText("Rich text")).toBeTruthy();
|
|
39
|
+
expect(getByText("Markdown")).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("Main shows the plural counts the controls seed", () => {
|
|
43
|
+
const { getByText } = mount(Main);
|
|
44
|
+
expect(getByText("42 words")).toBeTruthy();
|
|
45
|
+
expect(getByText("240 characters")).toBeTruthy();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("SingularCounts flips both nouns to singular at exactly one", () => {
|
|
49
|
+
const { getByText, queryByText } = mount(SingularCounts);
|
|
50
|
+
expect(getByText("1 word")).toBeTruthy();
|
|
51
|
+
expect(getByText("1 character")).toBeTruthy();
|
|
52
|
+
expect(queryByText("1 words")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("Empty pluralises at zero", () => {
|
|
56
|
+
const { getByText } = mount(Empty);
|
|
57
|
+
expect(getByText("0 words")).toBeTruthy();
|
|
58
|
+
expect(getByText("0 characters")).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("MarkdownMode marks the markdown label, not the rich-text one", () => {
|
|
62
|
+
const { getByText } = mount(MarkdownMode);
|
|
63
|
+
const classesOf = (label: string) => (getByText(label) as HTMLElement).className;
|
|
64
|
+
expect(classesOf("Markdown")).toContain("_fow-600");
|
|
65
|
+
expect(classesOf("Rich text")).toContain("_fow-400");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("pressing the idle label swaps the marking, so the toggle is live in the story", () => {
|
|
69
|
+
const { getByText } = mount(Main);
|
|
70
|
+
const classesOf = (label: string) => (getByText(label) as HTMLElement).className;
|
|
71
|
+
expect(classesOf("Rich text")).toContain("_fow-600");
|
|
72
|
+
fireEvent.click(getByText("Markdown"));
|
|
73
|
+
expect(classesOf("Markdown")).toContain("_fow-600");
|
|
74
|
+
expect(classesOf("Rich text")).toContain("_fow-400");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("InFrame puts the footer under stand-in body copy inside an EditorFrame", () => {
|
|
78
|
+
const { getByText } = mount(InFrame);
|
|
79
|
+
const body = getByText(/Twelve words of body copy/);
|
|
80
|
+
const footer = getByText("12 words");
|
|
81
|
+
expect(body).toBeTruthy();
|
|
82
|
+
expect(footer).toBeTruthy();
|
|
83
|
+
// The footer follows the body in document order (it is the frame's foot).
|
|
84
|
+
expect(body.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounts every ToolbarButton story with its args and asserts the visible
|
|
3
|
+
* contract each one exists to show, so a story that stops rendering what its
|
|
4
|
+
* name promises fails here before the gallery ships it.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { fireEvent } from "@testing-library/react";
|
|
8
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
9
|
+
|
|
10
|
+
// Stand-ins for the catalog Button and Tooltip, as in the toolbar specs next
|
|
11
|
+
// to this one. Both forward the attributes the stories exist to demonstrate:
|
|
12
|
+
// the DG-ST-03 aria-disabled state and the DG-OVL-05 tooltip.
|
|
13
|
+
vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
|
|
14
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
15
|
+
Button: (props: any) => (
|
|
16
|
+
<button
|
|
17
|
+
role="button"
|
|
18
|
+
aria-label={props["aria-label"]}
|
|
19
|
+
aria-disabled={props["aria-disabled"]}
|
|
20
|
+
aria-pressed={props["aria-pressed"]}
|
|
21
|
+
onClick={props.onPress}
|
|
22
|
+
data-active={props.theme === "active" ? "true" : undefined}
|
|
23
|
+
>
|
|
24
|
+
{props.children}
|
|
25
|
+
</button>
|
|
26
|
+
),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("@multiplatform.one/components", async (importOriginal) => ({
|
|
30
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
31
|
+
Tooltip: ({ content, children }: any) => <span data-tooltip={content}>{children}</span>,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const storiesModule = await import("./ToolbarButton.stories");
|
|
35
|
+
const meta = storiesModule.default;
|
|
36
|
+
const { Main, Active, Unavailable, WithTooltip, DropdownButton, Row } = storiesModule;
|
|
37
|
+
type ToolbarButtonStoryArgs = import("./ToolbarButton.stories").ToolbarButtonStoryArgs;
|
|
38
|
+
|
|
39
|
+
type StoryLike = {
|
|
40
|
+
args?: Partial<ToolbarButtonStoryArgs>;
|
|
41
|
+
render?: (args: ToolbarButtonStoryArgs) => React.ReactElement;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Resolve a story the way Storybook does: meta args, story args, story render. */
|
|
45
|
+
function mount(story: StoryLike) {
|
|
46
|
+
const args = { ...(meta.args as ToolbarButtonStoryArgs), ...story.args };
|
|
47
|
+
const render = story.render ?? (meta.render as StoryLike["render"]);
|
|
48
|
+
if (!render) throw new Error("story has no render");
|
|
49
|
+
const Story = () => render(args);
|
|
50
|
+
const Decorator = meta.decorators?.[0] as
|
|
51
|
+
| ((S: React.ComponentType) => React.ReactElement)
|
|
52
|
+
| undefined;
|
|
53
|
+
return renderWithProviders(Decorator ? Decorator(Story) : <Story />);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const stories = { Main, Active, Unavailable, WithTooltip, DropdownButton, Row } as const;
|
|
57
|
+
|
|
58
|
+
const buttonNamed = (container: HTMLElement, label: string) =>
|
|
59
|
+
container.querySelector<HTMLElement>(`[aria-label="${label}"]`);
|
|
60
|
+
|
|
61
|
+
describe("ToolbarButton stories", () => {
|
|
62
|
+
it("nests under the editor title so the gallery buckets it with RichTextEditor", () => {
|
|
63
|
+
expect(meta.title).toBe("Forms/RichTextEditor/ToolbarButton");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.each(Object.entries(stories))("%s mounts at least one labelled control", (_name, story) => {
|
|
67
|
+
const { container } = mount(story);
|
|
68
|
+
const buttons = container.querySelectorAll("[role='button']");
|
|
69
|
+
expect(buttons.length).toBeGreaterThan(0);
|
|
70
|
+
for (const button of Array.from(buttons)) {
|
|
71
|
+
expect(button.getAttribute("aria-label")).toBeTruthy();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("Main is available: it fires onPress and is not aria-disabled", () => {
|
|
76
|
+
const { container } = mount(Main);
|
|
77
|
+
const bold = buttonNamed(container, "Bold")!;
|
|
78
|
+
expect(bold.getAttribute("aria-disabled")).toBeNull();
|
|
79
|
+
fireEvent.click(bold);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("Active marks the button through the active theme", () => {
|
|
83
|
+
const { container } = mount(Active);
|
|
84
|
+
expect(buttonNamed(container, "Bold")!.getAttribute("data-active")).toBe("true");
|
|
85
|
+
// Main is the same button without the mark, so the story earns its place.
|
|
86
|
+
const plain = mount(Main);
|
|
87
|
+
expect(buttonNamed(plain.container, "Bold")!.getAttribute("data-active")).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("Unavailable reports aria-disabled and drops the tooltip (DG-ST-03 / DG-OVL-05)", () => {
|
|
91
|
+
const { container } = mount(Unavailable);
|
|
92
|
+
const undo = buttonNamed(container, "Undo")!;
|
|
93
|
+
expect(undo.getAttribute("aria-disabled")).toBe("true");
|
|
94
|
+
// Never bare-disabled: it stays focusable so AT can still reach it.
|
|
95
|
+
expect(undo.hasAttribute("disabled")).toBe(false);
|
|
96
|
+
expect(container.querySelector("[data-tooltip]")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("WithTooltip keeps the shortcut hint that Unavailable loses", () => {
|
|
100
|
+
const { container } = mount(WithTooltip);
|
|
101
|
+
const tooltip = container.querySelector("[data-tooltip]");
|
|
102
|
+
expect(tooltip).toBeTruthy();
|
|
103
|
+
expect(tooltip!.getAttribute("data-tooltip")).toBe("Strikethrough (⌘⇧X)");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("DropdownButton shows the caret variant in all three states", () => {
|
|
107
|
+
const { container } = mount(DropdownButton);
|
|
108
|
+
expect(buttonNamed(container, "Heading")).toBeTruthy();
|
|
109
|
+
expect(buttonNamed(container, "Lists")!.getAttribute("data-active")).toBe("true");
|
|
110
|
+
expect(buttonNamed(container, "Table")!.getAttribute("aria-disabled")).toBe("true");
|
|
111
|
+
// The caret is what separates this from the square button.
|
|
112
|
+
expect(container.querySelectorAll("svg").length).toBeGreaterThanOrEqual(3);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("Row lines the states up: unavailable, plain, active", () => {
|
|
116
|
+
const { container } = mount(Row);
|
|
117
|
+
expect(buttonNamed(container, "Undo")!.getAttribute("aria-disabled")).toBe("true");
|
|
118
|
+
expect(buttonNamed(container, "Redo")!.getAttribute("aria-disabled")).toBeNull();
|
|
119
|
+
expect(buttonNamed(container, "Bold")!.getAttribute("data-active")).toBe("true");
|
|
120
|
+
expect(buttonNamed(container, "Italic")!.getAttribute("data-active")).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounts every ToolbarGroup story with its args and asserts the visible
|
|
3
|
+
* contract each one exists to show, so a story that stops rendering what its
|
|
4
|
+
* name promises fails here before the gallery ships it.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
8
|
+
|
|
9
|
+
// The catalog Button and Tooltip are heavy under happy-dom; the toolbar specs
|
|
10
|
+
// next to this one already stand them in with a plain button, and the group's
|
|
11
|
+
// contract (which children render, and whether a separator follows) is
|
|
12
|
+
// unaffected by that swap.
|
|
13
|
+
vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
|
|
14
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
15
|
+
Button: (props: any) => (
|
|
16
|
+
<button role="button" aria-label={props["aria-label"]} onClick={props.onPress}>
|
|
17
|
+
{props.children}
|
|
18
|
+
</button>
|
|
19
|
+
),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("@multiplatform.one/components", async (importOriginal) => ({
|
|
23
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
24
|
+
Tooltip: ({ children }: any) => children,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const storiesModule = await import("./ToolbarGroup.stories");
|
|
28
|
+
const meta = storiesModule.default;
|
|
29
|
+
const { Main, NoSeparator, Hidden, TwoGroups } = storiesModule;
|
|
30
|
+
type ToolbarGroupStoryArgs = import("./ToolbarGroup.stories").ToolbarGroupStoryArgs;
|
|
31
|
+
|
|
32
|
+
type StoryLike = {
|
|
33
|
+
args?: Partial<ToolbarGroupStoryArgs>;
|
|
34
|
+
render?: (args: ToolbarGroupStoryArgs) => React.ReactElement;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Resolve a story the way Storybook does: meta args, story args, story render. */
|
|
38
|
+
function mount(story: StoryLike) {
|
|
39
|
+
const args = { ...(meta.args as ToolbarGroupStoryArgs), ...story.args };
|
|
40
|
+
const render = story.render ?? (meta.render as StoryLike["render"]);
|
|
41
|
+
if (!render) throw new Error("story has no render");
|
|
42
|
+
const Story = () => render(args);
|
|
43
|
+
const Decorator = meta.decorators?.[0] as
|
|
44
|
+
| ((S: React.ComponentType) => React.ReactElement)
|
|
45
|
+
| undefined;
|
|
46
|
+
return renderWithProviders(Decorator ? Decorator(Story) : <Story />);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The row the groups sit in. A ToolbarGroup renders a fragment
|
|
51
|
+
* (group frame + optional separator), so the row's element children ARE the
|
|
52
|
+
* groups and their separators — which is where "did the separator render"
|
|
53
|
+
* is observable, the separator being an unlabelled styled Separator.
|
|
54
|
+
*/
|
|
55
|
+
function rowOf(container: HTMLElement, anyButtonLabel: string): HTMLElement {
|
|
56
|
+
const button = container.querySelector<HTMLElement>(`[aria-label="${anyButtonLabel}"]`);
|
|
57
|
+
if (!button) throw new Error(`no button labelled ${anyButtonLabel}`);
|
|
58
|
+
const groupFrame = button.parentElement;
|
|
59
|
+
const row = groupFrame?.parentElement;
|
|
60
|
+
if (!row) throw new Error("no toolbar row");
|
|
61
|
+
return row;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const labelsIn = (container: HTMLElement) =>
|
|
65
|
+
Array.from(container.querySelectorAll("[aria-label]")).map((el) => el.getAttribute("aria-label"));
|
|
66
|
+
|
|
67
|
+
describe("ToolbarGroup stories", () => {
|
|
68
|
+
it("nests under the editor title so the gallery buckets it with RichTextEditor", () => {
|
|
69
|
+
expect(meta.title).toBe("Forms/RichTextEditor/ToolbarGroup");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("Main shows the history pair, the formatting trio and a separator between them", () => {
|
|
73
|
+
const { container } = mount(Main);
|
|
74
|
+
const labels = labelsIn(container);
|
|
75
|
+
expect(labels).toContain("Undo");
|
|
76
|
+
expect(labels).toContain("Redo");
|
|
77
|
+
expect(labels).toContain("Bold");
|
|
78
|
+
// group frame, separator, group frame
|
|
79
|
+
expect(rowOf(container, "Undo").children.length).toBe(3);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("NoSeparator keeps both groups but drops the rule between them", () => {
|
|
83
|
+
const { container } = mount(NoSeparator);
|
|
84
|
+
const labels = labelsIn(container);
|
|
85
|
+
expect(labels).toContain("Undo");
|
|
86
|
+
expect(labels).toContain("Bold");
|
|
87
|
+
expect(rowOf(container, "Undo").children.length).toBe(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("Hidden removes the group's buttons AND its separator, leaving the neighbour", () => {
|
|
91
|
+
const { container } = mount(Hidden);
|
|
92
|
+
const labels = labelsIn(container);
|
|
93
|
+
expect(labels).not.toContain("Undo");
|
|
94
|
+
expect(labels).not.toContain("Redo");
|
|
95
|
+
expect(labels).toContain("Bold");
|
|
96
|
+
expect(rowOf(container, "Bold").children.length).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("TwoGroups renders three clusters with a rule after each but the last", () => {
|
|
100
|
+
const { container } = mount(TwoGroups);
|
|
101
|
+
const labels = labelsIn(container);
|
|
102
|
+
expect(labels).toContain("Undo");
|
|
103
|
+
expect(labels).toContain("Bold");
|
|
104
|
+
expect(labels).toContain("Blockquote");
|
|
105
|
+
// three group frames + two separators
|
|
106
|
+
expect(rowOf(container, "Undo").children.length).toBe(5);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RichEditor.native.d.ts","sourceRoot":"","sources":["../src/RichEditor.native.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAWH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAI1C,OAAO,KAAK,EAAe,eAAe,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAI3F,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAUtD,MAAM,WAAW,uBAAwB,SAAQ,eAAe;IAC9D,gCAAgC;IAChC,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,6CAA6C;IAC7C,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAC;IAC5B,oEAAoE;IACpE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,kEAAkE;IAClE,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,+CAA+C;IAC/C,kBAAkB,CAAC,EAAE,CAAC,UAAU,EAAE,OAAO,KAAK,IAAI,CAAC;IACnD;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAMD,8EAA8E;AAC9E,iBAAS,mBAAmB,SAE3B;
|
|
1
|
+
{"version":3,"file":"RichEditor.native.d.ts","sourceRoot":"","sources":["../src/RichEditor.native.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAWH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAI1C,OAAO,KAAK,EAAe,eAAe,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAI3F,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAUtD,MAAM,WAAW,uBAAwB,SAAQ,eAAe;IAC9D,gCAAgC;IAChC,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,6CAA6C;IAC7C,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAC;IAC5B,oEAAoE;IACpE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,kEAAkE;IAClE,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,+CAA+C;IAC/C,kBAAkB,CAAC,EAAE,CAAC,UAAU,EAAE,OAAO,KAAK,IAAI,CAAC;IACnD;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAMD,8EAA8E;AAC9E,iBAAS,mBAAmB,SAE3B;AAigBD,eAAO,MAAM,UAAU;;;;CAIrB,CAAC"}
|
|
@@ -16,6 +16,10 @@ import type { RichEditorProps, RichEditorRef } from "../types";
|
|
|
16
16
|
export interface TentapEditorProps extends RichEditorProps {
|
|
17
17
|
/** Fill the available space (fullscreen modal) instead of using minHeight */
|
|
18
18
|
fill?: boolean;
|
|
19
|
+
/** CSS font-family string for body text (resolved from knobs by RichEditor) */
|
|
20
|
+
bodyFontFamily?: string;
|
|
21
|
+
/** CSS font-family string for headings (resolved from knobs by RichEditor) */
|
|
22
|
+
headingFontFamily?: string;
|
|
19
23
|
}
|
|
20
24
|
export declare const TentapEditor: import("react").ForwardRefExoticComponent<TentapEditorProps & import("react").RefAttributes<RichEditorRef>>;
|
|
21
25
|
//# sourceMappingURL=TentapEditor.native.d.ts.map
|