@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.
Files changed (35) hide show
  1. package/dist/cjs/RichEditor.native.js +20 -0
  2. package/dist/cjs/RichEditor.native.js.map +1 -1
  3. package/dist/cjs/editor/TentapEditor.native.js +18 -0
  4. package/dist/cjs/editor/TentapEditor.native.js.map +1 -1
  5. package/dist/esm/RichEditor.native.js +21 -1
  6. package/dist/esm/RichEditor.native.js.map +1 -1
  7. package/dist/esm/editor/TentapEditor.native.js +18 -0
  8. package/dist/esm/editor/TentapEditor.native.js.map +1 -1
  9. package/dist/jsx/RichEditor.native.js +20 -0
  10. package/dist/jsx/RichEditor.native.js.map +1 -1
  11. package/dist/jsx/editor/TentapEditor.native.js +18 -0
  12. package/dist/jsx/editor/TentapEditor.native.js.map +1 -1
  13. package/package.json +8 -9
  14. package/src/RichEditor.native.tsx +19 -1
  15. package/src/RichEditor.parts.stories.tsx +139 -0
  16. package/src/editor/SlashCommands.spec.tsx +210 -0
  17. package/src/editor/TentapEditor.native.tsx +35 -0
  18. package/src/richEditorPartsStories.spec.tsx +150 -0
  19. package/src/toolbar/CodeBlockBubble.spec.tsx +219 -0
  20. package/src/toolbar/FixedToolbar.stories.tsx +224 -0
  21. package/src/toolbar/ImageBubble.spec.tsx +140 -0
  22. package/src/toolbar/LinkBubble.spec.tsx +159 -0
  23. package/src/toolbar/ResizeHandle.spec.tsx +118 -0
  24. package/src/toolbar/StatusBar.spec.tsx +100 -0
  25. package/src/toolbar/StatusBar.stories.tsx +146 -0
  26. package/src/toolbar/TableBubble.spec.tsx +189 -0
  27. package/src/toolbar/ToolbarButton.stories.tsx +193 -0
  28. package/src/toolbar/ToolbarGroup.stories.tsx +148 -0
  29. package/src/toolbar/fixedToolbarStories.spec.tsx +200 -0
  30. package/src/toolbar/statusBarStories.spec.tsx +86 -0
  31. package/src/toolbar/toolbarButtonStories.spec.tsx +122 -0
  32. package/src/toolbar/toolbarGroupStories.spec.tsx +108 -0
  33. package/types/RichEditor.native.d.ts.map +1 -1
  34. package/types/editor/TentapEditor.native.d.ts +4 -0
  35. package/types/editor/TentapEditor.native.d.ts.map +1 -1
@@ -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;AA+eD,eAAO,MAAM,UAAU;;;;CAIrB,CAAC"}
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
@@ -1 +1 @@
1
- {"version":3,"file":"TentapEditor.native.d.ts","sourceRoot":"","sources":["../../src/editor/TentapEditor.native.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA0BH,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAoB9E,MAAM,WAAW,iBAAkB,SAAQ,eAAe;IACxD,6EAA6E;IAC7E,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAID,eAAO,MAAM,YAAY,6GA8PxB,CAAC"}
1
+ {"version":3,"file":"TentapEditor.native.d.ts","sourceRoot":"","sources":["../../src/editor/TentapEditor.native.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA0BH,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAqB9E,MAAM,WAAW,iBAAkB,SAAQ,eAAe;IACxD,6EAA6E;IAC7E,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,8EAA8E;IAC9E,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAID,eAAO,MAAM,YAAY,6GA4RxB,CAAC"}