@remit/ui 0.0.131 → 0.0.133
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 +1 -1
- package/src/components/compose-action-bar.stories.tsx +30 -6
- package/src/components/compose-action-bar.tsx +29 -11
- package/src/components/editor-focus.ts +78 -0
- package/src/components/plain-text-editor.mount.test.ts +27 -0
- package/src/components/plain-text-editor.tsx +2 -0
- package/src/components/rich-text-editor.mount.test.ts +94 -0
- package/src/components/rich-text-editor.tsx +2 -0
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@ const meta: Meta<typeof ComposeActionBar> = {
|
|
|
17
17
|
onSend: fn(),
|
|
18
18
|
onBlocked: fn(),
|
|
19
19
|
onDiscard: fn(),
|
|
20
|
-
|
|
20
|
+
save: { status: "idle" },
|
|
21
21
|
},
|
|
22
22
|
};
|
|
23
23
|
export default meta;
|
|
@@ -26,11 +26,35 @@ type Story = StoryObj<typeof ComposeActionBar>;
|
|
|
26
26
|
|
|
27
27
|
export const Ready: Story = {};
|
|
28
28
|
|
|
29
|
-
export const Saving: Story = { args: {
|
|
29
|
+
export const Saving: Story = { args: { save: { status: "saving" } } };
|
|
30
30
|
|
|
31
|
-
export const Saved: Story = { args: {
|
|
31
|
+
export const Saved: Story = { args: { save: { status: "saved" } } };
|
|
32
32
|
|
|
33
|
-
export const SaveFailed: Story = { args: {
|
|
33
|
+
export const SaveFailed: Story = { args: { save: { status: "error" } } };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Nothing has been written to the server yet and nothing will be until the
|
|
37
|
+
* draft has a To address to be created against. Silence here was the worst of
|
|
38
|
+
* both: the text was not being kept, and the composer looked exactly like one
|
|
39
|
+
* that had nothing to keep. The sentence names To rather than "a recipient",
|
|
40
|
+
* which a message addressed only in Cc already has.
|
|
41
|
+
*/
|
|
42
|
+
export const NotSavedYet: Story = {
|
|
43
|
+
name: "Unsaved — the draft has no To address yet",
|
|
44
|
+
args: {
|
|
45
|
+
send: { status: "blocked", reason: "Add a To address before sending." },
|
|
46
|
+
save: {
|
|
47
|
+
status: "unsaved",
|
|
48
|
+
reason: "Not saved — add a To address to keep this draft.",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
play: async ({ canvasElement }) => {
|
|
52
|
+
const canvas = within(canvasElement);
|
|
53
|
+
await expect(canvas.getByRole("status")).toHaveTextContent(
|
|
54
|
+
"Not saved — add a To address to keep this draft.",
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
34
58
|
|
|
35
59
|
export const Sending: Story = {
|
|
36
60
|
name: "Sending — also while the pending draft is written",
|
|
@@ -45,7 +69,7 @@ export const Sending: Story = {
|
|
|
45
69
|
export const NoRecipient: Story = {
|
|
46
70
|
name: "Blocked — nobody to send to",
|
|
47
71
|
args: {
|
|
48
|
-
send: { status: "blocked", reason: "Add
|
|
72
|
+
send: { status: "blocked", reason: "Add a To address before sending." },
|
|
49
73
|
},
|
|
50
74
|
render: (args) => {
|
|
51
75
|
const [reason, setReason] = useState<string>();
|
|
@@ -74,7 +98,7 @@ export const NoRecipient: Story = {
|
|
|
74
98
|
const canvas = within(canvasElement);
|
|
75
99
|
await userEvent.click(canvas.getByRole("button", { name: "Send" }));
|
|
76
100
|
await expect(canvas.getByTestId("compose-unavailable")).toHaveTextContent(
|
|
77
|
-
"Add
|
|
101
|
+
"Add a To address before sending.",
|
|
78
102
|
);
|
|
79
103
|
await expect(args.onSend).not.toHaveBeenCalled();
|
|
80
104
|
},
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { Loader2, Send, Trash2 } from "lucide-react";
|
|
2
2
|
import { Button } from "./button.js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* What the draft is doing, and — when it is not being saved — the sentence that
|
|
6
|
+
* says why. Built the same way as `ComposeSendState` and for the same reason:
|
|
7
|
+
* a composer holding text it is not persisting must never be able to say so
|
|
8
|
+
* without saying what is missing. The bare "idle" this replaces rendered
|
|
9
|
+
* nothing at all, so a message that could not be saved yet looked identical to
|
|
10
|
+
* one that had nothing to save.
|
|
11
|
+
*/
|
|
12
|
+
export type ComposeSaveState =
|
|
13
|
+
| { status: "idle" }
|
|
14
|
+
| { status: "saving" }
|
|
15
|
+
| { status: "saved" }
|
|
16
|
+
| { status: "error" }
|
|
17
|
+
| { status: "unsaved"; reason: string };
|
|
5
18
|
|
|
6
19
|
/**
|
|
7
20
|
* Whether Send can act, and when it cannot, the sentence that says why.
|
|
@@ -22,20 +35,25 @@ export interface ComposeActionBarProps {
|
|
|
22
35
|
/** Called with the reason when Send is pressed while it cannot act. */
|
|
23
36
|
onBlocked: (reason: string) => void;
|
|
24
37
|
onDiscard: () => void;
|
|
25
|
-
|
|
38
|
+
save?: ComposeSaveState;
|
|
26
39
|
}
|
|
27
40
|
|
|
28
|
-
const
|
|
29
|
-
if (status === "saving") {
|
|
41
|
+
const SaveStateIndicator = ({ save }: { save: ComposeSaveState }) => {
|
|
42
|
+
if (save.status === "saving") {
|
|
30
43
|
return (
|
|
31
|
-
<
|
|
44
|
+
<output className="animate-pulse text-xs text-fg-muted">Saving...</output>
|
|
32
45
|
);
|
|
33
46
|
}
|
|
34
|
-
if (status === "saved") {
|
|
35
|
-
return <
|
|
47
|
+
if (save.status === "saved") {
|
|
48
|
+
return <output className="text-xs text-fg-muted">Draft saved</output>;
|
|
49
|
+
}
|
|
50
|
+
if (save.status === "error") {
|
|
51
|
+
return <output className="text-xs text-danger">Save failed</output>;
|
|
36
52
|
}
|
|
37
|
-
if (status === "
|
|
38
|
-
return
|
|
53
|
+
if (save.status === "unsaved") {
|
|
54
|
+
return (
|
|
55
|
+
<output className="truncate text-xs text-warning">{save.reason}</output>
|
|
56
|
+
);
|
|
39
57
|
}
|
|
40
58
|
return null;
|
|
41
59
|
};
|
|
@@ -51,7 +69,7 @@ export function ComposeActionBar({
|
|
|
51
69
|
onSend,
|
|
52
70
|
onBlocked,
|
|
53
71
|
onDiscard,
|
|
54
|
-
|
|
72
|
+
save = { status: "idle" },
|
|
55
73
|
}: ComposeActionBarProps) {
|
|
56
74
|
const sending = send.status === "sending";
|
|
57
75
|
const blockedReason = send.status === "blocked" ? send.reason : undefined;
|
|
@@ -84,7 +102,7 @@ export function ComposeActionBar({
|
|
|
84
102
|
>
|
|
85
103
|
Send
|
|
86
104
|
</Button>
|
|
87
|
-
<
|
|
105
|
+
<SaveStateIndicator save={save} />
|
|
88
106
|
</div>
|
|
89
107
|
<Button
|
|
90
108
|
variant="ghost"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the reader is already typing somewhere else.
|
|
3
|
+
*
|
|
4
|
+
* Both writing surfaces open with the caret in the message, and both arrive on
|
|
5
|
+
* their own lazily-loaded chunk — so "on mount" is whenever that chunk lands.
|
|
6
|
+
* On a cold cache that is well after the composer opened and the reader moved
|
|
7
|
+
* on to the recipients, the subject or the search field, and claiming the caret
|
|
8
|
+
* then takes it out of a sentence in progress: the rest of what they type goes
|
|
9
|
+
* into the message body.
|
|
10
|
+
*
|
|
11
|
+
* Pressing a button to open the composer leaves focus on that button, which is
|
|
12
|
+
* not typing — so the ordinary open still lands the caret in the message.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The types an `<input>` accepts prose in. Everything else it can be — a
|
|
17
|
+
* checkbox, a radio, a button, a file or colour picker — is a control the
|
|
18
|
+
* reader clicks, not one they are mid-word in.
|
|
19
|
+
*/
|
|
20
|
+
const TEXT_INPUT_TYPES = new Set([
|
|
21
|
+
"text",
|
|
22
|
+
"search",
|
|
23
|
+
"email",
|
|
24
|
+
"url",
|
|
25
|
+
"tel",
|
|
26
|
+
"password",
|
|
27
|
+
"number",
|
|
28
|
+
"date",
|
|
29
|
+
"datetime-local",
|
|
30
|
+
"month",
|
|
31
|
+
"time",
|
|
32
|
+
"week",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Focus as the reader experiences it. `activeElement` stops at a shadow host,
|
|
37
|
+
* so a field inside a web component reports as the host; this walks in to the
|
|
38
|
+
* element actually holding the caret.
|
|
39
|
+
*/
|
|
40
|
+
const deepActiveElement = (scope: Document | ShadowRoot): Element | null => {
|
|
41
|
+
const active = scope.activeElement;
|
|
42
|
+
if (!active?.shadowRoot) return active;
|
|
43
|
+
return deepActiveElement(active.shadowRoot) ?? active;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether text goes into this element. Read off the tag and the `type`
|
|
48
|
+
* attribute rather than `instanceof`, which is per-realm and answers wrongly
|
|
49
|
+
* for anything reached through a frame.
|
|
50
|
+
*/
|
|
51
|
+
const takesTyping = (element: Element): boolean => {
|
|
52
|
+
const tag = element.tagName.toUpperCase();
|
|
53
|
+
// A focused frame is a document of its own — whatever is being typed in
|
|
54
|
+
// there is not ours to interrupt, and we cannot see it to ask.
|
|
55
|
+
if (tag === "IFRAME" || tag === "FRAME") return true;
|
|
56
|
+
if (tag === "TEXTAREA") return true;
|
|
57
|
+
// Typeahead: a reader part-way through selecting an option is choosing with
|
|
58
|
+
// the keyboard, and losing it drops them somewhere they did not pick.
|
|
59
|
+
if (tag === "SELECT") return true;
|
|
60
|
+
if (tag === "INPUT") {
|
|
61
|
+
const type = (element.getAttribute("type") ?? "text").toLowerCase();
|
|
62
|
+
return TEXT_INPUT_TYPES.has(type);
|
|
63
|
+
}
|
|
64
|
+
// The attribute as well as the property: `isContentEditable` is computed by
|
|
65
|
+
// the engine, and a document that never lays out does not answer for it.
|
|
66
|
+
const editable = element.getAttribute("contenteditable");
|
|
67
|
+
if (editable !== null && editable.toLowerCase() !== "false") return true;
|
|
68
|
+
return (element as Partial<HTMLElement>).isContentEditable === true;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const isWritingElsewhere = (root: HTMLElement | null): boolean => {
|
|
72
|
+
const owner = root?.ownerDocument ?? globalThis.document;
|
|
73
|
+
if (!owner) return false;
|
|
74
|
+
const active = deepActiveElement(owner);
|
|
75
|
+
if (!active || active === root) return false;
|
|
76
|
+
if (root?.contains(active)) return false;
|
|
77
|
+
return takesTyping(active);
|
|
78
|
+
};
|
|
@@ -147,6 +147,33 @@ describe("PlainTextEditor", () => {
|
|
|
147
147
|
assert.equal(textarea.selectionStart, textarea.value.length);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The surface arrives in the same lazily-loaded chunk as the rich editor, so
|
|
152
|
+
* its mount is whenever that chunk lands rather than when the composer
|
|
153
|
+
* opened. Resuming a plain-text draft is the reachable path: the draft read
|
|
154
|
+
* remounts the body once it resolves, by which time the reader may well be
|
|
155
|
+
* typing in the search field.
|
|
156
|
+
*/
|
|
157
|
+
it("leaves the caret in a field the reader is typing in", async () => {
|
|
158
|
+
const elsewhere = document.createElement("input");
|
|
159
|
+
elsewhere.setAttribute("aria-label", "Search mail");
|
|
160
|
+
document.body.append(elsewhere);
|
|
161
|
+
elsewhere.focus();
|
|
162
|
+
|
|
163
|
+
await mount("", { initialCaret: "start" });
|
|
164
|
+
await act(async () => {
|
|
165
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const holder = document.activeElement?.getAttribute("aria-label");
|
|
169
|
+
elsewhere.remove();
|
|
170
|
+
assert.equal(
|
|
171
|
+
holder,
|
|
172
|
+
"Search mail",
|
|
173
|
+
`the caret ended up on ${holder ?? "nothing"}`,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
150
177
|
it("sends on Cmd+Enter", async () => {
|
|
151
178
|
let sent = 0;
|
|
152
179
|
await mount("Ready to go.", {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useState,
|
|
9
9
|
} from "react";
|
|
10
10
|
import { Banner } from "./banner.js";
|
|
11
|
+
import { isWritingElsewhere } from "./editor-focus.js";
|
|
11
12
|
import { htmlToMarkdown } from "./rich-text-document.js";
|
|
12
13
|
import type { ComposeCaret } from "./rich-text-value.js";
|
|
13
14
|
|
|
@@ -95,6 +96,7 @@ export const PlainTextEditor = ({
|
|
|
95
96
|
const textarea = textareaRef.current;
|
|
96
97
|
if (!textarea) return;
|
|
97
98
|
const timer = setTimeout(() => {
|
|
99
|
+
if (isWritingElsewhere(textarea)) return;
|
|
98
100
|
textarea.focus();
|
|
99
101
|
const at = initialCaret === "start" ? 0 : textarea.value.length;
|
|
100
102
|
textarea.setSelectionRange(at, at);
|
|
@@ -118,3 +118,97 @@ describe("RichTextEditor", () => {
|
|
|
118
118
|
assert.equal(field.value, "https://");
|
|
119
119
|
});
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The writing surface is loaded on its own chunk, so it mounts whenever that
|
|
124
|
+
* chunk arrives rather than when the composer opened. A reader who pressed
|
|
125
|
+
* Compose and started typing in the search field, the recipients or the subject
|
|
126
|
+
* is mid-sentence by then, and the caret is theirs.
|
|
127
|
+
*/
|
|
128
|
+
describe("RichTextEditor opening on a caret", () => {
|
|
129
|
+
const mount = async (): Promise<void> => {
|
|
130
|
+
await act(async () => {
|
|
131
|
+
root = createRoot(container);
|
|
132
|
+
root.render(createElement(RichTextEditor, { initialCaret: "start" }));
|
|
133
|
+
});
|
|
134
|
+
// The caret is claimed off a timer, so let it run.
|
|
135
|
+
await act(async () => {
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
it("takes the caret when nothing else holds it", async () => {
|
|
141
|
+
await mount();
|
|
142
|
+
|
|
143
|
+
const editable = container.querySelector<HTMLElement>(
|
|
144
|
+
"[data-testid=compose-body]",
|
|
145
|
+
);
|
|
146
|
+
assert.ok(editable, "the editable surface is mounted");
|
|
147
|
+
assert.equal(document.activeElement === editable, true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One case per kind of thing that can hold focus when the chunk lands. The
|
|
152
|
+
* open is only allowed to lose to something the reader is typing in.
|
|
153
|
+
*/
|
|
154
|
+
const holders: readonly [string, () => HTMLElement, boolean][] = [
|
|
155
|
+
[
|
|
156
|
+
"a search field",
|
|
157
|
+
() => {
|
|
158
|
+
const field = document.createElement("input");
|
|
159
|
+
field.setAttribute("aria-label", "Search mail");
|
|
160
|
+
return field;
|
|
161
|
+
},
|
|
162
|
+
true,
|
|
163
|
+
],
|
|
164
|
+
[
|
|
165
|
+
"a subject field with no type attribute",
|
|
166
|
+
() => document.createElement("input"),
|
|
167
|
+
true,
|
|
168
|
+
],
|
|
169
|
+
["a plain textarea", () => document.createElement("textarea"), true],
|
|
170
|
+
[
|
|
171
|
+
"a select being typed through",
|
|
172
|
+
() => document.createElement("select"),
|
|
173
|
+
true,
|
|
174
|
+
],
|
|
175
|
+
[
|
|
176
|
+
"another contenteditable",
|
|
177
|
+
() => {
|
|
178
|
+
const surface = document.createElement("div");
|
|
179
|
+
surface.setAttribute("contenteditable", "true");
|
|
180
|
+
return surface;
|
|
181
|
+
},
|
|
182
|
+
true,
|
|
183
|
+
],
|
|
184
|
+
[
|
|
185
|
+
"the button that opened the composer",
|
|
186
|
+
() => document.createElement("button"),
|
|
187
|
+
false,
|
|
188
|
+
],
|
|
189
|
+
[
|
|
190
|
+
"a checkbox",
|
|
191
|
+
() => {
|
|
192
|
+
const box = document.createElement("input");
|
|
193
|
+
box.setAttribute("type", "checkbox");
|
|
194
|
+
return box;
|
|
195
|
+
},
|
|
196
|
+
false,
|
|
197
|
+
],
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
for (const [what, build, keepsIt] of holders) {
|
|
201
|
+
it(`${keepsIt ? "leaves the caret on" : "takes the caret from"} ${what}`, async () => {
|
|
202
|
+
const elsewhere = build();
|
|
203
|
+
elsewhere.setAttribute("data-holder", "");
|
|
204
|
+
document.body.append(elsewhere);
|
|
205
|
+
elsewhere.focus();
|
|
206
|
+
|
|
207
|
+
await mount();
|
|
208
|
+
|
|
209
|
+
const held = document.activeElement?.hasAttribute("data-holder") === true;
|
|
210
|
+
elsewhere.remove();
|
|
211
|
+
assert.equal(held, keepsIt);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
});
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
useRef,
|
|
31
31
|
useState,
|
|
32
32
|
} from "react";
|
|
33
|
+
import { isWritingElsewhere } from "./editor-focus.js";
|
|
33
34
|
import { RichTextCorrectionMenu } from "./rich-text-correction-menu.js";
|
|
34
35
|
import { $adoptHtml, $readRichText } from "./rich-text-document.js";
|
|
35
36
|
import { RICH_TEXT_NODES, richTextTheme } from "./rich-text-nodes.js";
|
|
@@ -886,6 +887,7 @@ const AutoFocus = ({ caret }: { caret?: ComposeCaret }) => {
|
|
|
886
887
|
// the caret is placed here rather than left to `focus`, which would open a
|
|
887
888
|
// new message below the signature instead of above it.
|
|
888
889
|
const timer = setTimeout(() => {
|
|
890
|
+
if (isWritingElsewhere(editor.getRootElement())) return;
|
|
889
891
|
editor.update(
|
|
890
892
|
() => {
|
|
891
893
|
const root = $getRoot();
|
package/src/index.ts
CHANGED
|
@@ -166,7 +166,7 @@ export { Checkbox, type CheckboxProps } from "./components/checkbox.js";
|
|
|
166
166
|
export {
|
|
167
167
|
ComposeActionBar,
|
|
168
168
|
type ComposeActionBarProps,
|
|
169
|
-
type
|
|
169
|
+
type ComposeSaveState,
|
|
170
170
|
type ComposeSendState,
|
|
171
171
|
} from "./components/compose-action-bar.js";
|
|
172
172
|
export {
|