@remit/ui 0.0.109 → 0.0.110
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.render.test.ts +7 -5
- package/src/components/compose-action-bar.stories.tsx +36 -19
- package/src/components/compose-action-bar.tsx +31 -23
- package/src/components/compose-body-skeleton.tsx +10 -0
- package/src/components/compose-body.stories.tsx +66 -1
- package/src/components/compose-body.tsx +11 -6
- package/src/components/compose-header.stories.tsx +27 -1
- package/src/components/compose-header.tsx +18 -6
- package/src/components/compose-language-chip.stories.tsx +35 -1
- package/src/components/compose-language-chip.tsx +20 -2
- package/src/components/compose-smtp-missing-banner.tsx +15 -3
- package/src/components/plain-text-editor.mount.test.ts +4 -4
- package/src/components/plain-text-editor.stories.tsx +1 -0
- package/src/components/plain-text-editor.tsx +20 -14
- package/src/components/rich-text-editor.stories.tsx +54 -0
- package/src/components/rich-text-editor.tsx +20 -8
- package/src/components/rich-text-formatting.test.ts +3 -2
- package/src/components/rich-text-image-node.test.ts +157 -0
- package/src/components/rich-text-image-node.ts +124 -0
- package/src/components/rich-text-nodes.ts +5 -1
- package/src/components/rich-text-paste.test.ts +48 -0
- package/src/components/rich-text-value.ts +6 -0
- package/src/index.ts +3 -0
- package/src/lib/adopted-html.test.ts +23 -0
- package/src/lib/adopted-html.ts +16 -1
- package/src/rich-text.ts +1 -0
package/package.json
CHANGED
|
@@ -5,10 +5,10 @@ import { renderToString } from "react-dom/server";
|
|
|
5
5
|
import { ComposeActionBar } from "./compose-action-bar.js";
|
|
6
6
|
|
|
7
7
|
const baseProps = {
|
|
8
|
+
send: { status: "ready" } as const,
|
|
8
9
|
onSend: () => undefined,
|
|
10
|
+
onBlocked: () => undefined,
|
|
9
11
|
onDiscard: () => undefined,
|
|
10
|
-
sending: false,
|
|
11
|
-
canSend: true,
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
describe("ComposeActionBar", () => {
|
|
@@ -23,8 +23,7 @@ describe("ComposeActionBar", () => {
|
|
|
23
23
|
const html = renderToString(
|
|
24
24
|
createElement(ComposeActionBar, {
|
|
25
25
|
...baseProps,
|
|
26
|
-
|
|
27
|
-
unavailableReason: "SMTP not configured",
|
|
26
|
+
send: { status: "blocked", reason: "SMTP not configured" },
|
|
28
27
|
}),
|
|
29
28
|
);
|
|
30
29
|
assert.match(html, /Send/);
|
|
@@ -34,7 +33,10 @@ describe("ComposeActionBar", () => {
|
|
|
34
33
|
|
|
35
34
|
it("uses aria-busy while sending", () => {
|
|
36
35
|
const html = renderToString(
|
|
37
|
-
createElement(ComposeActionBar, {
|
|
36
|
+
createElement(ComposeActionBar, {
|
|
37
|
+
...baseProps,
|
|
38
|
+
send: { status: "sending" },
|
|
39
|
+
}),
|
|
38
40
|
);
|
|
39
41
|
assert.match(html, /aria-busy="true"/);
|
|
40
42
|
assert.doesNotMatch(html, /disabled=""/);
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import { expect, userEvent, within } from "storybook/test";
|
|
3
|
+
import { expect, fn, userEvent, within } from "storybook/test";
|
|
4
4
|
import { ComposeActionBar } from "./compose-action-bar.js";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Send, Discard, and what the draft is doing. Send is never greyed out and
|
|
8
|
+
* never silent: a state that cannot send carries the sentence that says why,
|
|
9
|
+
* and the press reports it.
|
|
10
|
+
*/
|
|
6
11
|
const meta: Meta<typeof ComposeActionBar> = {
|
|
7
12
|
title: "Mail/ComposeActionBar",
|
|
8
13
|
component: ComposeActionBar,
|
|
9
14
|
parameters: { layout: "padded" },
|
|
10
15
|
args: {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
send: { status: "ready" },
|
|
17
|
+
onSend: fn(),
|
|
18
|
+
onBlocked: fn(),
|
|
19
|
+
onDiscard: fn(),
|
|
15
20
|
saveStatus: "idle",
|
|
16
21
|
},
|
|
17
22
|
};
|
|
@@ -29,17 +34,20 @@ export const SaveFailed: Story = { args: { saveStatus: "error" } };
|
|
|
29
34
|
|
|
30
35
|
export const Sending: Story = {
|
|
31
36
|
name: "Sending — also while the pending draft is written",
|
|
32
|
-
args: {
|
|
37
|
+
args: { send: { status: "sending" } },
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
|
-
* Send is never greyed out. Pressing it with
|
|
41
|
+
* Send is never greyed out. Pressing it with nobody to send to reports the
|
|
37
42
|
* reason where the app would raise its banner — a control that swallowed the
|
|
38
43
|
* press would be the dead button this bar exists to avoid.
|
|
39
44
|
*/
|
|
40
|
-
export const
|
|
41
|
-
name: "
|
|
42
|
-
|
|
45
|
+
export const NoRecipient: Story = {
|
|
46
|
+
name: "Blocked — nobody to send to",
|
|
47
|
+
args: {
|
|
48
|
+
send: { status: "blocked", reason: "Add at least one recipient." },
|
|
49
|
+
},
|
|
50
|
+
render: (args) => {
|
|
43
51
|
const [reason, setReason] = useState<string>();
|
|
44
52
|
return (
|
|
45
53
|
<div className="space-y-2">
|
|
@@ -53,22 +61,31 @@ export const CannotSend: Story = {
|
|
|
53
61
|
</div>
|
|
54
62
|
)}
|
|
55
63
|
<ComposeActionBar
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
unavailableReason="SMTP not configured"
|
|
62
|
-
onUnavailable={setReason}
|
|
64
|
+
{...args}
|
|
65
|
+
onBlocked={(next) => {
|
|
66
|
+
args.onBlocked(next);
|
|
67
|
+
setReason(next);
|
|
68
|
+
}}
|
|
63
69
|
/>
|
|
64
70
|
</div>
|
|
65
71
|
);
|
|
66
72
|
},
|
|
67
|
-
play: async ({ canvasElement }) => {
|
|
73
|
+
play: async ({ args, canvasElement }) => {
|
|
68
74
|
const canvas = within(canvasElement);
|
|
69
75
|
await userEvent.click(canvas.getByRole("button", { name: "Send" }));
|
|
70
76
|
await expect(canvas.getByTestId("compose-unavailable")).toHaveTextContent(
|
|
71
|
-
"
|
|
77
|
+
"Add at least one recipient.",
|
|
72
78
|
);
|
|
79
|
+
await expect(args.onSend).not.toHaveBeenCalled();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const SmtpMissing: Story = {
|
|
84
|
+
name: "Blocked — the account cannot send",
|
|
85
|
+
args: {
|
|
86
|
+
send: {
|
|
87
|
+
status: "blocked",
|
|
88
|
+
reason: "This account can't send mail until SMTP is configured.",
|
|
89
|
+
},
|
|
73
90
|
},
|
|
74
91
|
};
|
|
@@ -3,20 +3,26 @@ import { Button } from "./button.js";
|
|
|
3
3
|
|
|
4
4
|
export type ComposeSaveStatus = "idle" | "saving" | "saved" | "error";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Whether Send can act, and when it cannot, the sentence that says why.
|
|
8
|
+
*
|
|
9
|
+
* A blocked state cannot be built without its reason, so no arrangement of
|
|
10
|
+
* props produces a Send press that reports nothing. The pair this replaces
|
|
11
|
+
* could: a message with no recipient set `canSend` false and left the reason
|
|
12
|
+
* undefined, and the press did nothing at all.
|
|
13
|
+
*/
|
|
14
|
+
export type ComposeSendState =
|
|
15
|
+
| { status: "ready" }
|
|
16
|
+
| { status: "sending" }
|
|
17
|
+
| { status: "blocked"; reason: string };
|
|
18
|
+
|
|
6
19
|
export interface ComposeActionBarProps {
|
|
20
|
+
send: ComposeSendState;
|
|
7
21
|
onSend: () => void;
|
|
22
|
+
/** Called with the reason when Send is pressed while it cannot act. */
|
|
23
|
+
onBlocked: (reason: string) => void;
|
|
8
24
|
onDiscard: () => void;
|
|
9
|
-
sending: boolean;
|
|
10
|
-
canSend: boolean;
|
|
11
25
|
saveStatus?: ComposeSaveStatus;
|
|
12
|
-
/**
|
|
13
|
-
* Why sending is unavailable (e.g. "SMTP not configured"). Shown on the
|
|
14
|
-
* Send button as a tooltip; surfaced to the user on press via
|
|
15
|
-
* `onUnavailable` rather than disabling the control.
|
|
16
|
-
*/
|
|
17
|
-
unavailableReason?: string;
|
|
18
|
-
/** Called when Send is pressed while it cannot act — explain, don't disable. */
|
|
19
|
-
onUnavailable?: (reason: string) => void;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
const SaveStatusIndicator = ({ status }: { status: ComposeSaveStatus }) => {
|
|
@@ -35,29 +41,31 @@ const SaveStatusIndicator = ({ status }: { status: ComposeSaveStatus }) => {
|
|
|
35
41
|
};
|
|
36
42
|
|
|
37
43
|
/**
|
|
38
|
-
* Compose footer: Send + Discard. Send stays pressable
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* clips below the fold on mobile.
|
|
44
|
+
* Compose footer: Send + Discard. Send stays pressable when it cannot act and
|
|
45
|
+
* says why on press instead of greying out — a dead control leaves the user
|
|
46
|
+
* guessing whether the app is broken or the message is. The pill keeps a fixed
|
|
47
|
+
* min-height so it never clips below the fold on mobile.
|
|
42
48
|
*/
|
|
43
49
|
export function ComposeActionBar({
|
|
50
|
+
send,
|
|
44
51
|
onSend,
|
|
52
|
+
onBlocked,
|
|
45
53
|
onDiscard,
|
|
46
|
-
sending,
|
|
47
|
-
canSend,
|
|
48
54
|
saveStatus = "idle",
|
|
49
|
-
unavailableReason,
|
|
50
|
-
onUnavailable,
|
|
51
55
|
}: ComposeActionBarProps) {
|
|
56
|
+
const sending = send.status === "sending";
|
|
57
|
+
const blockedReason = send.status === "blocked" ? send.reason : undefined;
|
|
58
|
+
|
|
52
59
|
return (
|
|
53
60
|
<div className="flex items-center justify-between border-t border-line px-3 py-2">
|
|
54
|
-
<div className="flex items-center gap-3">
|
|
61
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
55
62
|
<Button
|
|
56
63
|
variant="primary"
|
|
57
64
|
size="md"
|
|
58
65
|
aria-busy={sending}
|
|
59
|
-
title={
|
|
60
|
-
className="min-h-11 rounded-full px-4"
|
|
66
|
+
title={blockedReason}
|
|
67
|
+
className="min-h-11 shrink-0 rounded-full px-4"
|
|
68
|
+
data-testid="compose-send"
|
|
61
69
|
icon={
|
|
62
70
|
sending ? (
|
|
63
71
|
<Loader2 className="size-4 animate-spin" />
|
|
@@ -67,8 +75,8 @@ export function ComposeActionBar({
|
|
|
67
75
|
}
|
|
68
76
|
onClick={() => {
|
|
69
77
|
if (sending) return;
|
|
70
|
-
if (
|
|
71
|
-
|
|
78
|
+
if (blockedReason !== undefined) {
|
|
79
|
+
onBlocked(blockedReason);
|
|
72
80
|
return;
|
|
73
81
|
}
|
|
74
82
|
onSend();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const ComposeBodySkeleton = () => (
|
|
2
|
+
<div
|
|
3
|
+
aria-hidden="true"
|
|
4
|
+
data-testid="compose-body-skeleton"
|
|
5
|
+
className="min-h-[120px] px-3 py-2"
|
|
6
|
+
>
|
|
7
|
+
<div className="mb-2 h-8 animate-pulse rounded bg-surface-sunken" />
|
|
8
|
+
<div className="min-h-[80px] animate-pulse rounded bg-surface-sunken/50" />
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
@@ -33,20 +33,33 @@ const DUTCH_PROSE =
|
|
|
33
33
|
/** The languages a Dutch-writing account has configured, most-used first. */
|
|
34
34
|
const LANGUAGES = ["nl", "en", "de"];
|
|
35
35
|
|
|
36
|
+
const SIGNED_DOCUMENT = "<p></p><p>-- </p><p>Matthijs</p>";
|
|
37
|
+
|
|
38
|
+
const LONG_PLAIN_PROSE = [
|
|
39
|
+
"Beste Anna, hierbij de planning voor volgende week zoals besproken tijdens de vergadering van donderdag.",
|
|
40
|
+
"",
|
|
41
|
+
"| Region | Total |",
|
|
42
|
+
"| --- | --- |",
|
|
43
|
+
"| EMEA | 412 |",
|
|
44
|
+
].join("\n");
|
|
45
|
+
|
|
36
46
|
const noop = () => undefined;
|
|
37
47
|
|
|
38
48
|
const Harness = ({
|
|
39
49
|
initialHtml = "",
|
|
40
50
|
initialText = "",
|
|
41
51
|
startIn = "rich",
|
|
52
|
+
initialCaret,
|
|
42
53
|
onConversionError,
|
|
43
54
|
conversions,
|
|
44
55
|
languages = LANGUAGES,
|
|
45
56
|
quoted,
|
|
57
|
+
width = 680,
|
|
46
58
|
}: {
|
|
47
59
|
initialHtml?: string;
|
|
48
60
|
initialText?: string;
|
|
49
61
|
startIn?: "rich" | "plain";
|
|
62
|
+
initialCaret?: "start" | "end";
|
|
50
63
|
onConversionError?: (failure: ConversionFailure) => void;
|
|
51
64
|
conversions?: {
|
|
52
65
|
toPlain: (value: RichTextValue) => string;
|
|
@@ -54,11 +67,15 @@ const Harness = ({
|
|
|
54
67
|
};
|
|
55
68
|
languages?: string[];
|
|
56
69
|
quoted?: string;
|
|
70
|
+
width?: number;
|
|
57
71
|
}) => {
|
|
58
72
|
const [mode, setMode] = useState<"rich" | "plain">(startIn);
|
|
59
73
|
const [failure, setFailure] = useState<ConversionFailure>();
|
|
60
74
|
return (
|
|
61
|
-
<div
|
|
75
|
+
<div
|
|
76
|
+
style={{ width }}
|
|
77
|
+
className="flex h-[460px] flex-col overflow-auto rounded-md border border-line bg-canvas"
|
|
78
|
+
>
|
|
62
79
|
{failure && (
|
|
63
80
|
<div
|
|
64
81
|
role="alert"
|
|
@@ -74,6 +91,7 @@ const Harness = ({
|
|
|
74
91
|
onModeChange={setMode}
|
|
75
92
|
initialHtml={initialHtml}
|
|
76
93
|
initialText={initialText}
|
|
94
|
+
initialCaret={initialCaret}
|
|
77
95
|
onChange={noop}
|
|
78
96
|
onConversionError={(reported) => {
|
|
79
97
|
setFailure(reported);
|
|
@@ -136,6 +154,53 @@ export const RichDocument: Story = {
|
|
|
136
154
|
args: { initialHtml: RICH_DOCUMENT },
|
|
137
155
|
};
|
|
138
156
|
|
|
157
|
+
/**
|
|
158
|
+
* A new message opens above its signature. The caret used to land at the end of
|
|
159
|
+
* the document, which on any account that has one put the first keystroke under
|
|
160
|
+
* the sign-off.
|
|
161
|
+
*/
|
|
162
|
+
export const OpensAboveTheSignature: Story = {
|
|
163
|
+
name: "A new message opens above the signature",
|
|
164
|
+
args: { initialHtml: SIGNED_DOCUMENT, initialCaret: "start" },
|
|
165
|
+
play: async ({ canvasElement }) => {
|
|
166
|
+
const editable = canvasElement.querySelector<HTMLElement>(
|
|
167
|
+
"[data-testid=compose-body]",
|
|
168
|
+
);
|
|
169
|
+
if (!editable) throw new Error("the rich surface is not mounted");
|
|
170
|
+
|
|
171
|
+
await waitFor(async () => {
|
|
172
|
+
await expect(editable).toHaveFocus();
|
|
173
|
+
});
|
|
174
|
+
await userEvent.keyboard("Hoi Anna");
|
|
175
|
+
|
|
176
|
+
await waitFor(async () => {
|
|
177
|
+
const text = editable.textContent ?? "";
|
|
178
|
+
await expect(text).toContain("Hoi Anna");
|
|
179
|
+
await expect(text.indexOf("Hoi Anna")).toBeLessThan(
|
|
180
|
+
text.indexOf("Matthijs"),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Plain prose wraps. A message whose every paragraph ran off the right edge
|
|
188
|
+
* could not be read back at all, which is the worse of the two failures at 390 —
|
|
189
|
+
* a pipe table wider than the surface is the one that gives.
|
|
190
|
+
*/
|
|
191
|
+
export const PlainProseWraps: Story = {
|
|
192
|
+
name: "Plain, wrapping at a phone's width",
|
|
193
|
+
args: { startIn: "plain", initialText: LONG_PLAIN_PROSE, width: 390 },
|
|
194
|
+
play: async ({ canvasElement }) => {
|
|
195
|
+
const textarea = plainSurface(canvasElement);
|
|
196
|
+
if (!textarea) throw new Error("the plain surface is not mounted");
|
|
197
|
+
// A pixel of tolerance: sub-pixel layout rounding, not a scrollbar.
|
|
198
|
+
await expect(textarea.scrollWidth).toBeLessThanOrEqual(
|
|
199
|
+
textarea.clientWidth + 1,
|
|
200
|
+
);
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
139
204
|
export const PlainDraft: Story = {
|
|
140
205
|
name: "Plain, reopened from Markdown",
|
|
141
206
|
args: { startIn: "plain", initialText: PLAIN_MARKDOWN },
|
|
@@ -9,7 +9,7 @@ import { ConfirmDialog } from "./confirm-dialog.js";
|
|
|
9
9
|
import { PlainTextEditor } from "./plain-text-editor.js";
|
|
10
10
|
import { markdownToHtml } from "./rich-text-document.js";
|
|
11
11
|
import { RichTextEditor } from "./rich-text-editor.js";
|
|
12
|
-
import type { RichTextValue } from "./rich-text-value.js";
|
|
12
|
+
import type { ComposeCaret, RichTextValue } from "./rich-text-value.js";
|
|
13
13
|
import { useComposeLanguage } from "./use-compose-language.js";
|
|
14
14
|
|
|
15
15
|
export interface ConversionFailure {
|
|
@@ -49,7 +49,11 @@ export interface ComposeBodyProps {
|
|
|
49
49
|
initialText: string;
|
|
50
50
|
onChange: (value: RichTextValue) => void;
|
|
51
51
|
onSubmit?: () => void;
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Where the caret lands when the composer opens. Absent, the surface does
|
|
54
|
+
* not take focus.
|
|
55
|
+
*/
|
|
56
|
+
initialCaret?: ComposeCaret;
|
|
53
57
|
onConversionError: (failure: ConversionFailure) => void;
|
|
54
58
|
conversions?: ComposeConversions;
|
|
55
59
|
/** The account's writing languages, most-used first. */
|
|
@@ -73,7 +77,7 @@ export const ComposeBody = ({
|
|
|
73
77
|
initialText,
|
|
74
78
|
onChange,
|
|
75
79
|
onSubmit,
|
|
76
|
-
|
|
80
|
+
initialCaret,
|
|
77
81
|
onConversionError,
|
|
78
82
|
conversions = DEFAULT_COMPOSE_CONVERSIONS,
|
|
79
83
|
languages,
|
|
@@ -97,7 +101,7 @@ export const ComposeBody = ({
|
|
|
97
101
|
|
|
98
102
|
// Detection reads the body the user typed. The quoted reply block is not part
|
|
99
103
|
// of it — that lives outside the editor, in the form's own `quoted` slot.
|
|
100
|
-
const { language, choose } = useComposeLanguage({
|
|
104
|
+
const { language, source, choose } = useComposeLanguage({
|
|
101
105
|
languages,
|
|
102
106
|
text: bodyText,
|
|
103
107
|
initialLanguage,
|
|
@@ -166,6 +170,7 @@ export const ComposeBody = ({
|
|
|
166
170
|
<ComposeLanguageChip
|
|
167
171
|
language={language}
|
|
168
172
|
languages={languages}
|
|
173
|
+
source={source}
|
|
169
174
|
onSelect={choose}
|
|
170
175
|
/>
|
|
171
176
|
<ComposeModeToggle mode={mode} onToggle={handleToggle} />
|
|
@@ -179,7 +184,7 @@ export const ComposeBody = ({
|
|
|
179
184
|
value={plainText}
|
|
180
185
|
onChange={handlePlainChange}
|
|
181
186
|
onSubmit={onSubmit}
|
|
182
|
-
|
|
187
|
+
initialCaret={focusSwitchedSurface ? "end" : initialCaret}
|
|
183
188
|
lang={language}
|
|
184
189
|
trailing={trailing}
|
|
185
190
|
/>
|
|
@@ -189,7 +194,7 @@ export const ComposeBody = ({
|
|
|
189
194
|
initialHtml={richHtml}
|
|
190
195
|
onChange={handleRichChange}
|
|
191
196
|
onSubmit={onSubmit}
|
|
192
|
-
|
|
197
|
+
initialCaret={focusSwitchedSurface ? "end" : initialCaret}
|
|
193
198
|
lang={language}
|
|
194
199
|
trailing={trailing}
|
|
195
200
|
/>
|
|
@@ -49,11 +49,13 @@ const Harness = ({
|
|
|
49
49
|
const [showCc, setShowCc] = useState(initialCc !== undefined);
|
|
50
50
|
const [showBcc, setShowBcc] = useState(initialBcc !== undefined);
|
|
51
51
|
const [subject, setSubject] = useState(initialSubject);
|
|
52
|
+
const [isCollapsed, setIsCollapsed] = useState(collapsed);
|
|
52
53
|
|
|
53
54
|
return (
|
|
54
55
|
<div className="w-[560px] border border-line bg-surface">
|
|
55
56
|
<ComposeHeader
|
|
56
|
-
collapsed={
|
|
57
|
+
collapsed={isCollapsed}
|
|
58
|
+
onExpand={() => setIsCollapsed(false)}
|
|
57
59
|
summary={composeHeaderSummary({ to, cc, bcc, subject })}
|
|
58
60
|
from={<FromRow email="alice@northwind.example" />}
|
|
59
61
|
to={
|
|
@@ -133,6 +135,30 @@ export const CollapsedAndEmpty: Story = {
|
|
|
133
135
|
render: () => <Harness collapsed />,
|
|
134
136
|
};
|
|
135
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The collapsed line is the way back. Pressing it puts the recipient rows
|
|
140
|
+
* back — with the keyboard up it is the only route to Cc, Bcc and the subject,
|
|
141
|
+
* and a bar that looks pressable and is not reads as a broken app.
|
|
142
|
+
*/
|
|
143
|
+
export const CollapsedExpandsOnPress: Story = {
|
|
144
|
+
render: () => (
|
|
145
|
+
<Harness
|
|
146
|
+
collapsed
|
|
147
|
+
initialTo={[
|
|
148
|
+
{ email: "ada@northwind.example", displayName: "Ada Lovelace" },
|
|
149
|
+
]}
|
|
150
|
+
initialSubject="Re: Q3 planning"
|
|
151
|
+
/>
|
|
152
|
+
),
|
|
153
|
+
play: async ({ canvasElement }) => {
|
|
154
|
+
const canvas = within(canvasElement);
|
|
155
|
+
await userEvent.click(
|
|
156
|
+
canvas.getByRole("button", { name: "Show recipients and subject" }),
|
|
157
|
+
);
|
|
158
|
+
await expect(canvas.getByLabelText("To:")).toBeVisible();
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
136
162
|
export const RevealingCcLeavesBccOnOffer: Story = {
|
|
137
163
|
render: () => <Harness />,
|
|
138
164
|
play: async ({ canvasElement }) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ChevronDown } from "lucide-react";
|
|
1
2
|
import type { ReactNode } from "react";
|
|
2
3
|
import type { AddressEntry } from "./compose-address-field.js";
|
|
3
4
|
|
|
@@ -10,6 +11,12 @@ export interface ComposeHeaderProps {
|
|
|
10
11
|
collapsed?: boolean;
|
|
11
12
|
/** One line of what the collapsed header stands in for. */
|
|
12
13
|
summary?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Puts the recipient rows back. Required, because the collapsed line is the
|
|
16
|
+
* only way back to Cc, Bcc and the subject while the keyboard is up, and a
|
|
17
|
+
* bar that looks pressable and is not is the failure this replaces.
|
|
18
|
+
*/
|
|
19
|
+
onExpand: () => void;
|
|
13
20
|
from: ReactNode;
|
|
14
21
|
to: ReactNode;
|
|
15
22
|
/** Present once the field has been revealed; absent leaves the reveal button. */
|
|
@@ -65,6 +72,7 @@ export const composeHeaderSummary = ({
|
|
|
65
72
|
export const ComposeHeader = ({
|
|
66
73
|
collapsed = false,
|
|
67
74
|
summary = "",
|
|
75
|
+
onExpand,
|
|
68
76
|
from,
|
|
69
77
|
to,
|
|
70
78
|
cc,
|
|
@@ -75,15 +83,19 @@ export const ComposeHeader = ({
|
|
|
75
83
|
}: ComposeHeaderProps) => {
|
|
76
84
|
if (collapsed) {
|
|
77
85
|
return (
|
|
78
|
-
<
|
|
79
|
-
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={onExpand}
|
|
89
|
+
aria-label="Show recipients and subject"
|
|
90
|
+
className="flex min-h-11 w-full items-center gap-2 overflow-hidden border-b border-line px-3 py-1.5 text-left transition-colors hover:bg-surface-sunken"
|
|
80
91
|
data-testid="compose-header-collapsed"
|
|
81
92
|
>
|
|
82
93
|
<span className="truncate text-xs text-fg-muted">{summary || "…"}</span>
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
<ChevronDown
|
|
95
|
+
className="ml-auto size-4 shrink-0 text-fg-muted"
|
|
96
|
+
aria-hidden="true"
|
|
97
|
+
/>
|
|
98
|
+
</button>
|
|
87
99
|
);
|
|
88
100
|
}
|
|
89
101
|
|
|
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
|
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { expect, userEvent, within } from "storybook/test";
|
|
4
4
|
import { ComposeLanguageChip } from "./compose-language-chip.js";
|
|
5
|
+
import type { ComposeLanguageSource } from "./use-compose-language.js";
|
|
5
6
|
|
|
6
7
|
const LANGUAGES = ["nl", "en", "de"];
|
|
7
8
|
|
|
@@ -14,9 +15,11 @@ const FIREFOX_HELP =
|
|
|
14
15
|
|
|
15
16
|
const Chip = ({
|
|
16
17
|
initial = "nl",
|
|
18
|
+
source = "account",
|
|
17
19
|
helpText,
|
|
18
20
|
}: {
|
|
19
21
|
initial?: string;
|
|
22
|
+
source?: ComposeLanguageSource;
|
|
20
23
|
helpText?: string;
|
|
21
24
|
}) => {
|
|
22
25
|
const [language, setLanguage] = useState(initial);
|
|
@@ -25,6 +28,7 @@ const Chip = ({
|
|
|
25
28
|
<ComposeLanguageChip
|
|
26
29
|
language={language}
|
|
27
30
|
languages={LANGUAGES}
|
|
31
|
+
source={language === initial ? source : "manual"}
|
|
28
32
|
onSelect={setLanguage}
|
|
29
33
|
helpText={helpText}
|
|
30
34
|
/>
|
|
@@ -50,10 +54,40 @@ export default meta;
|
|
|
50
54
|
type Story = StoryObj<typeof Chip>;
|
|
51
55
|
|
|
52
56
|
export const Closed: Story = {
|
|
53
|
-
name: "The chip",
|
|
57
|
+
name: "The chip — the account's own language",
|
|
54
58
|
args: {},
|
|
55
59
|
};
|
|
56
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The control changes under the user while they type, so the value alone does
|
|
63
|
+
* not say where it came from. The tint marks a message that is not going out in
|
|
64
|
+
* the account's own language, and the accessible name says whether the tag was
|
|
65
|
+
* guessed, chosen or inherited.
|
|
66
|
+
*/
|
|
67
|
+
export const NotTheAccountLanguage: Story = {
|
|
68
|
+
name: "Detected — a message in another language",
|
|
69
|
+
args: { initial: "de", source: "detected" },
|
|
70
|
+
play: async ({ canvasElement }) => {
|
|
71
|
+
const chip = within(canvasElement).getByTestId("compose-language-chip");
|
|
72
|
+
await expect(chip).toHaveAttribute("data-language-source", "detected");
|
|
73
|
+
await expect(chip.getAttribute("aria-label")).toContain(
|
|
74
|
+
"detected from what you wrote",
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const ChosenByHand: Story = {
|
|
80
|
+
name: "Chosen — detection stops for this message",
|
|
81
|
+
args: { initial: "de", source: "manual" },
|
|
82
|
+
play: async ({ canvasElement }) => {
|
|
83
|
+
const chip = within(canvasElement).getByTestId("compose-language-chip");
|
|
84
|
+
await expect(chip).toHaveAttribute("data-language-source", "manual");
|
|
85
|
+
await expect(chip.getAttribute("aria-label")).toContain(
|
|
86
|
+
"chosen for this message",
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
57
91
|
const openMenu = async (canvasElement: HTMLElement): Promise<void> => {
|
|
58
92
|
await userEvent.click(
|
|
59
93
|
within(canvasElement).getByRole("button", {
|
|
@@ -7,12 +7,25 @@ import {
|
|
|
7
7
|
} from "../lib/compose-language.js";
|
|
8
8
|
import { rovingNextIndex } from "../lib/roving-focus.js";
|
|
9
9
|
import { Button } from "./button.js";
|
|
10
|
+
import type { ComposeLanguageSource } from "./use-compose-language.js";
|
|
11
|
+
|
|
12
|
+
const sourceNote: Record<ComposeLanguageSource, string> = {
|
|
13
|
+
account: "the account default",
|
|
14
|
+
detected: "detected from what you wrote",
|
|
15
|
+
manual: "chosen for this message",
|
|
16
|
+
};
|
|
10
17
|
|
|
11
18
|
export interface ComposeLanguageChipProps {
|
|
12
19
|
/** The BCP 47 tag the message is currently being written in. */
|
|
13
20
|
language: string;
|
|
14
21
|
/** The account's configured tags — the menu, and detection's candidate set. */
|
|
15
22
|
languages: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Where the tag came from. The control changes under the user while they
|
|
25
|
+
* type, so its name says whether the value was guessed, chosen or inherited
|
|
26
|
+
* from the account rather than leaving two letters to stand for all three.
|
|
27
|
+
*/
|
|
28
|
+
source: ComposeLanguageSource;
|
|
16
29
|
onSelect: (tag: string) => void;
|
|
17
30
|
/**
|
|
18
31
|
* The sentence naming where the browser keeps its dictionary setting.
|
|
@@ -32,6 +45,7 @@ export interface ComposeLanguageChipProps {
|
|
|
32
45
|
export const ComposeLanguageChip = ({
|
|
33
46
|
language,
|
|
34
47
|
languages,
|
|
48
|
+
source,
|
|
35
49
|
onSelect,
|
|
36
50
|
helpText,
|
|
37
51
|
}: ComposeLanguageChipProps) => {
|
|
@@ -98,14 +112,18 @@ export const ComposeLanguageChip = ({
|
|
|
98
112
|
ref={triggerRef}
|
|
99
113
|
variant="ghost"
|
|
100
114
|
size="md"
|
|
101
|
-
aria-label={`Message language: ${languageLabel(language)}`}
|
|
115
|
+
aria-label={`Message language: ${languageLabel(language)}, ${sourceNote[source]}`}
|
|
102
116
|
aria-haspopup="menu"
|
|
103
117
|
aria-expanded={open}
|
|
104
118
|
aria-controls={open ? menuId : undefined}
|
|
105
119
|
onClick={() => setOpen((value) => !value)}
|
|
106
120
|
data-testid="compose-language-chip"
|
|
107
121
|
data-language={language}
|
|
108
|
-
|
|
122
|
+
data-language-source={source}
|
|
123
|
+
className={cn(
|
|
124
|
+
"min-h-11 min-w-11 shrink-0 px-2 font-medium",
|
|
125
|
+
language !== languages[0] && "bg-accent-2-soft text-accent-2",
|
|
126
|
+
)}
|
|
109
127
|
>
|
|
110
128
|
{languageChipLabel(language)}
|
|
111
129
|
</Button>
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { AlertTriangle, ArrowRight } from "lucide-react";
|
|
2
|
+
import type { Ref } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one sentence this failure gets. The banner states it, and Send refuses
|
|
6
|
+
* with the same words — a second wording of the same fact reads as a second
|
|
7
|
+
* problem.
|
|
8
|
+
*/
|
|
9
|
+
export const SMTP_MISSING_MESSAGE =
|
|
10
|
+
"This account can't send mail until SMTP is configured.";
|
|
2
11
|
|
|
3
12
|
export interface ComposeSmtpMissingBannerProps {
|
|
4
13
|
onConfigure: () => void;
|
|
14
|
+
/** The fix, so a blocked Send can put the cursor on it. */
|
|
15
|
+
configureRef?: Ref<HTMLButtonElement>;
|
|
5
16
|
}
|
|
6
17
|
|
|
7
18
|
/**
|
|
@@ -12,6 +23,7 @@ export interface ComposeSmtpMissingBannerProps {
|
|
|
12
23
|
*/
|
|
13
24
|
export const ComposeSmtpMissingBanner = ({
|
|
14
25
|
onConfigure,
|
|
26
|
+
configureRef,
|
|
15
27
|
}: ComposeSmtpMissingBannerProps) => (
|
|
16
28
|
<div
|
|
17
29
|
role="alert"
|
|
@@ -23,12 +35,12 @@ export const ComposeSmtpMissingBanner = ({
|
|
|
23
35
|
aria-hidden="true"
|
|
24
36
|
/>
|
|
25
37
|
<div className="flex-1 min-w-0">
|
|
26
|
-
<p className="text-sm font-medium text-warning">
|
|
27
|
-
This account can't send mail until SMTP is configured.
|
|
28
|
-
</p>
|
|
38
|
+
<p className="text-sm font-medium text-warning">{SMTP_MISSING_MESSAGE}</p>
|
|
29
39
|
<button
|
|
40
|
+
ref={configureRef}
|
|
30
41
|
type="button"
|
|
31
42
|
onClick={onConfigure}
|
|
43
|
+
data-testid="compose-smtp-configure"
|
|
32
44
|
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-warning hover:underline"
|
|
33
45
|
>
|
|
34
46
|
Configure SMTP
|