@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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Storybook stories for FixedToolbar — the bar across the top of the editor,
3
+ * the thing the whole toolbar/ folder assembles into.
4
+ *
5
+ * Stories: Main, Sections, MarkdownCapabilities, ErrorState, FullscreenToggle,
6
+ * LiveEditor
7
+ *
8
+ * The toolbar owns no editor state: it renders `state` and calls commands
9
+ * through `editorRef`. So every story but the last drives it from a plain
10
+ * state object and reports the commands into the actions panel, which is the
11
+ * only way to see the command wiring without a document to run it against.
12
+ * LiveEditor is the exception — a real editor behind a standalone bar, which
13
+ * is what the compound `RichEditor.Editor` export exists for.
14
+ */
15
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
16
+ import { action } from "@multiplatform.one/storybook";
17
+ import { useRef, useState } from "react";
18
+ import { Paragraph, YStack } from "tamagui";
19
+ import { RichEditorContext } from "../context";
20
+ import { EditorFrame } from "../styled";
21
+ import { RichEditor } from "../RichEditor";
22
+ import { defaultEditorState } from "../types";
23
+ import type { EditorCapabilities, EditorState, RichEditorRef, ToolbarSection } from "../types";
24
+ import { FixedToolbar } from "./FixedToolbar";
25
+
26
+ export interface FixedToolbarStoryArgs {
27
+ /** Bold is on in the current selection */
28
+ isBold: boolean;
29
+ /** Italic is on in the current selection */
30
+ isItalic: boolean;
31
+ /** There is history to undo (an empty history washes the Undo button) */
32
+ canUndo: boolean;
33
+ /** There is history to redo */
34
+ canRedo: boolean;
35
+ /** Which sections show; undefined shows every one */
36
+ sections?: ToolbarSection[];
37
+ /** markdown drops the controls GFM cannot express */
38
+ capabilities: EditorCapabilities;
39
+ /** Paints the bar's bottom rule red, matching the frame */
40
+ hasError: boolean;
41
+ }
42
+
43
+ function stateFor(args: FixedToolbarStoryArgs): EditorState {
44
+ return {
45
+ ...defaultEditorState,
46
+ isBold: args.isBold,
47
+ isItalic: args.isItalic,
48
+ canUndo: args.canUndo,
49
+ canRedo: args.canRedo,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * A toolbar with no editor under it. `editorRef` stays null, so commands are
55
+ * reported to the actions panel instead of running — which is the point:
56
+ * the bar's job is to name the command, not to perform it.
57
+ */
58
+ function ToolbarSpecimen(args: FixedToolbarStoryArgs) {
59
+ const editorRef = useRef<RichEditorRef>({
60
+ executeCommand: (command, payload) => action("executeCommand")(command, payload),
61
+ getState: () => stateFor(args),
62
+ getHTML: () => "",
63
+ getJSON: () => null,
64
+ getMarkdown: () => "",
65
+ setHTML: () => {},
66
+ setJSON: () => {},
67
+ setMarkdown: () => {},
68
+ focus: () => {},
69
+ blur: () => {},
70
+ });
71
+ return (
72
+ <EditorFrame maxWidth={720} borderRadius="$3" hasError={args.hasError}>
73
+ <FixedToolbar
74
+ editorRef={editorRef}
75
+ state={stateFor(args)}
76
+ sections={args.sections}
77
+ capabilities={args.capabilities}
78
+ hasError={args.hasError}
79
+ />
80
+ <YStack minHeight={100} padding="$3">
81
+ <Paragraph size="$3" color="$color11">
82
+ The bar above drives this space. Commands land in the Actions panel.
83
+ </Paragraph>
84
+ </YStack>
85
+ </EditorFrame>
86
+ );
87
+ }
88
+
89
+ const meta: Meta<FixedToolbarStoryArgs> = {
90
+ title: "Forms/RichTextEditor/FixedToolbar",
91
+ component: FixedToolbar as never,
92
+ parameters: {
93
+ layout: "padded",
94
+ },
95
+ argTypes: {
96
+ isBold: { control: "boolean" },
97
+ isItalic: { control: "boolean" },
98
+ canUndo: { control: "boolean" },
99
+ canRedo: { control: "boolean" },
100
+ capabilities: { control: "radio", options: ["full", "markdown"] },
101
+ hasError: { control: "boolean" },
102
+ sections: { control: false },
103
+ },
104
+ args: {
105
+ isBold: false,
106
+ isItalic: false,
107
+ canUndo: true,
108
+ canRedo: false,
109
+ capabilities: "full",
110
+ hasError: false,
111
+ },
112
+ decorators: [
113
+ (Story) => (
114
+ <RichEditorContext.Provider size="$true" compact={false} disabled={false}>
115
+ <Story />
116
+ </RichEditorContext.Provider>
117
+ ),
118
+ ],
119
+ render: (args) => <ToolbarSpecimen {...args} />,
120
+ };
121
+
122
+ export default meta;
123
+ type Story = StoryObj<FixedToolbarStoryArgs>;
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Main (with controls) — every section, bold off, redo unavailable
127
+ // ---------------------------------------------------------------------------
128
+
129
+ export const Main: Story = {};
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Sections — the `sections` prop trims the bar to the listed groups
133
+ // ---------------------------------------------------------------------------
134
+
135
+ export const Sections: Story = {
136
+ args: {
137
+ sections: ["formatting", "link"],
138
+ isBold: true,
139
+ },
140
+ };
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // MarkdownCapabilities — GFM cannot express colour or underline, so those
144
+ // controls are absent, not merely disabled
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export const MarkdownCapabilities: Story = {
148
+ name: "Markdown capabilities",
149
+ args: {
150
+ capabilities: "markdown",
151
+ },
152
+ };
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // ErrorState — the bar's bottom rule turns red with the frame
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export const ErrorState: Story = {
159
+ name: "Error state",
160
+ args: {
161
+ hasError: true,
162
+ },
163
+ };
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // FullscreenToggle — onFullscreenToggle adds the expand control at the right
167
+ // end, outside the scrolling strip so it never scrolls away
168
+ // ---------------------------------------------------------------------------
169
+
170
+ export const FullscreenToggle: Story = {
171
+ name: "Fullscreen toggle",
172
+ render: (args) => {
173
+ const FullscreenSpecimen = () => {
174
+ const [fullscreen, setFullscreen] = useState(false);
175
+ const editorRef = useRef<RichEditorRef>(null);
176
+ return (
177
+ <EditorFrame maxWidth={720} borderRadius="$3">
178
+ <FixedToolbar
179
+ editorRef={editorRef}
180
+ state={stateFor(args)}
181
+ fullscreen={fullscreen}
182
+ onFullscreenToggle={() => {
183
+ action("onFullscreenToggle")(!fullscreen);
184
+ setFullscreen(!fullscreen);
185
+ }}
186
+ />
187
+ <YStack minHeight={100} padding="$3">
188
+ <Paragraph size="$3" color="$color11">
189
+ {fullscreen ? "Fullscreen: on" : "Fullscreen: off"}
190
+ </Paragraph>
191
+ </YStack>
192
+ </EditorFrame>
193
+ );
194
+ };
195
+ return <FullscreenSpecimen />;
196
+ },
197
+ };
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // LiveEditor — a standalone bar wired to a real editor through the compound
201
+ // `RichEditor.Editor` export; typing updates the marks
202
+ // ---------------------------------------------------------------------------
203
+
204
+ export const LiveEditor: Story = {
205
+ name: "Live editor",
206
+ render: () => {
207
+ const LiveSpecimen = () => {
208
+ const editorRef = useRef<RichEditorRef>(null);
209
+ const [state, setState] = useState<EditorState>(defaultEditorState);
210
+ return (
211
+ <EditorFrame maxWidth={720} borderRadius="$3">
212
+ <FixedToolbar editorRef={editorRef} state={state} />
213
+ <RichEditor.Editor
214
+ ref={editorRef}
215
+ initialContent="<p>Select this text and press <strong>B</strong> in the bar above — the button marks itself from the editor's own state.</p>"
216
+ onStateChange={setState}
217
+ minHeight={160}
218
+ />
219
+ </EditorFrame>
220
+ );
221
+ };
222
+ return <LiveSpecimen />;
223
+ },
224
+ };
@@ -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
+ };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Storybook stories for ToolbarButton and ToolbarDropdownButton — the two
3
+ * pressables every toolbar cluster is built from.
4
+ *
5
+ * Stories: Main, Active, Unavailable, WithTooltip, DropdownButton, Row
6
+ *
7
+ * Two things are worth seeing here and nowhere else. The pressable is a 44x44
8
+ * target (Axiom 12) while the visible chrome is a 30x30 box inside it, so the
9
+ * strip stays dense without shrinking the hit area. And an unavailable control
10
+ * is never bare-`disabled` (DG-ST-03): it keeps focus, reports `aria-disabled`,
11
+ * dims, drops its hover surface, and loses its tooltip (DG-OVL-05).
12
+ */
13
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
14
+ import { action } from "@multiplatform.one/storybook";
15
+ import { XStack } from "tamagui";
16
+ import { RichEditorContext } from "../context";
17
+ import { ToolbarFrame } from "../styled";
18
+ import { BoldIcon, ItalicIcon, RedoIcon, StrikeIcon, UndoIcon } from "./icons";
19
+ import { ToolbarButton, ToolbarDropdownButton } from "./ToolbarButton";
20
+ import { ToolbarGroup } from "./ToolbarGroup";
21
+
22
+ export interface ToolbarButtonStoryArgs {
23
+ /** Format is on: the chrome box takes the active surface */
24
+ active: boolean;
25
+ /** Unavailable: aria-disabled, dimmed, no hover surface, no tooltip */
26
+ disabled: boolean;
27
+ /** Shortcut hint on hover; suppressed while unavailable */
28
+ tooltip: string;
29
+ /** Accessible name — required, since the glyph carries no text */
30
+ "aria-label": string;
31
+ }
32
+
33
+ const meta: Meta<ToolbarButtonStoryArgs> = {
34
+ title: "Forms/RichTextEditor/ToolbarButton",
35
+ component: ToolbarButton as never,
36
+ parameters: {
37
+ layout: "padded",
38
+ },
39
+ argTypes: {
40
+ active: { control: "boolean" },
41
+ disabled: { control: "boolean" },
42
+ tooltip: { control: "text" },
43
+ "aria-label": { control: "text" },
44
+ },
45
+ args: {
46
+ active: false,
47
+ disabled: false,
48
+ tooltip: "Bold (⌘B)",
49
+ "aria-label": "Bold",
50
+ },
51
+ decorators: [
52
+ (Story) => (
53
+ <RichEditorContext.Provider size="$true" compact={false} disabled={false}>
54
+ <Story />
55
+ </RichEditorContext.Provider>
56
+ ),
57
+ ],
58
+ render: (args) => (
59
+ <ToolbarFrame width={80} justifyContent="center">
60
+ <ToolbarButton
61
+ active={args.active}
62
+ disabled={args.disabled}
63
+ tooltip={args.tooltip}
64
+ aria-label={args["aria-label"]}
65
+ onPress={() => action("onPress")(args["aria-label"])}
66
+ >
67
+ <BoldIcon>B</BoldIcon>
68
+ </ToolbarButton>
69
+ </ToolbarFrame>
70
+ ),
71
+ };
72
+
73
+ export default meta;
74
+ type Story = StoryObj<ToolbarButtonStoryArgs>;
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Main (with controls)
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export const Main: Story = {};
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Active — the format is on, so the chrome box takes the active surface
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export const Active: Story = {
87
+ args: {
88
+ active: true,
89
+ },
90
+ };
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Unavailable — DG-ST-03: aria-disabled and washed, never bare `disabled`
94
+ // ---------------------------------------------------------------------------
95
+
96
+ export const Unavailable: Story = {
97
+ args: {
98
+ disabled: true,
99
+ "aria-label": "Undo",
100
+ tooltip: "Undo (⌘Z)",
101
+ },
102
+ render: (args) => (
103
+ <ToolbarFrame width={80} justifyContent="center">
104
+ <ToolbarButton
105
+ disabled={args.disabled}
106
+ tooltip={args.tooltip}
107
+ aria-label={args["aria-label"]}
108
+ onPress={() => action("onPress")(args["aria-label"])}
109
+ >
110
+ <UndoIcon />
111
+ </ToolbarButton>
112
+ </ToolbarFrame>
113
+ ),
114
+ };
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // WithTooltip — the shortcut hint that returns with availability
118
+ // ---------------------------------------------------------------------------
119
+
120
+ export const WithTooltip: Story = {
121
+ name: "With tooltip",
122
+ args: {
123
+ tooltip: "Strikethrough (⌘⇧X)",
124
+ "aria-label": "Strikethrough",
125
+ },
126
+ render: (args) => (
127
+ <ToolbarFrame width={80} justifyContent="center">
128
+ <ToolbarButton
129
+ tooltip={args.tooltip}
130
+ aria-label={args["aria-label"]}
131
+ onPress={() => action("onPress")(args["aria-label"])}
132
+ >
133
+ <StrikeIcon>S</StrikeIcon>
134
+ </ToolbarButton>
135
+ </ToolbarFrame>
136
+ ),
137
+ };
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // DropdownButton — the caret variant: fixed height, width follows its label
141
+ // ---------------------------------------------------------------------------
142
+
143
+ export const DropdownButton: Story = {
144
+ name: "Dropdown button",
145
+ render: () => (
146
+ <ToolbarFrame width={220} justifyContent="center">
147
+ <XStack alignItems="center" gap="$1">
148
+ <ToolbarDropdownButton aria-label="Heading" onPress={() => action("onPress")("Heading")}>
149
+ H1
150
+ </ToolbarDropdownButton>
151
+ <ToolbarDropdownButton active aria-label="Lists" onPress={() => action("onPress")("Lists")}>
152
+ Bullets
153
+ </ToolbarDropdownButton>
154
+ <ToolbarDropdownButton
155
+ disabled
156
+ aria-label="Table"
157
+ onPress={() => action("onPress")("Table")}
158
+ >
159
+ Table
160
+ </ToolbarDropdownButton>
161
+ </XStack>
162
+ </ToolbarFrame>
163
+ ),
164
+ };
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Row — the four states side by side, in the bar they live in
168
+ // ---------------------------------------------------------------------------
169
+
170
+ export const Row: Story = {
171
+ render: () => (
172
+ <ToolbarFrame width={320}>
173
+ <XStack alignItems="center" gap="$1" paddingHorizontal="$2">
174
+ <ToolbarGroup>
175
+ <ToolbarButton disabled onPress={() => {}} aria-label="Undo" tooltip="Undo (⌘Z)">
176
+ <UndoIcon />
177
+ </ToolbarButton>
178
+ <ToolbarButton onPress={() => action("onPress")("Redo")} aria-label="Redo">
179
+ <RedoIcon />
180
+ </ToolbarButton>
181
+ </ToolbarGroup>
182
+ <ToolbarGroup showSeparator={false}>
183
+ <ToolbarButton active onPress={() => action("onPress")("Bold")} aria-label="Bold">
184
+ <BoldIcon>B</BoldIcon>
185
+ </ToolbarButton>
186
+ <ToolbarButton onPress={() => action("onPress")("Italic")} aria-label="Italic">
187
+ <ItalicIcon>I</ItalicIcon>
188
+ </ToolbarButton>
189
+ </ToolbarGroup>
190
+ </XStack>
191
+ </ToolbarFrame>
192
+ ),
193
+ };