@remit/ui 0.0.102 → 0.0.103
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-address-field.stories.tsx +138 -0
- package/src/components/compose-address-field.tsx +206 -0
- package/src/components/compose-body.stories.tsx +463 -0
- package/src/components/compose-body.tsx +210 -0
- package/src/components/compose-header.stories.tsx +147 -0
- package/src/components/compose-header.tsx +109 -0
- package/src/components/compose-mode-toggle.stories.tsx +53 -0
- package/src/components/compose-smtp-missing-banner.stories.tsx +30 -0
- package/src/components/compose-smtp-missing-banner.tsx +39 -0
- package/src/components/compose-subject-field.tsx +22 -0
- package/src/components/confirm-dialog.stories.tsx +54 -0
- package/src/components/confirm-dialog.tsx +140 -0
- package/src/index.ts +23 -0
- package/src/lib/compose-mode.test.ts +76 -0
- package/src/lib/compose-mode.ts +50 -0
- package/src/rich-text.ts +7 -0
- package/src/components/compose-form-shell.stories.tsx +0 -96
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which surface a draft reopens in, and when a mode switch is refused.
|
|
3
|
+
*
|
|
4
|
+
* The mode is derived from `htmlBody`, with no field of its own. It has to be
|
|
5
|
+
* "a non-empty string" and not "truthy": the rich editor serializes an empty
|
|
6
|
+
* document to `<p><br></p>` and a plain draft clears the column to `""`, so a
|
|
7
|
+
* falsy check opens a plain draft correctly by accident and an absent column
|
|
8
|
+
* — an old draft, a partial write — the wrong way round.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import {
|
|
13
|
+
conversionOutcome,
|
|
14
|
+
modeOfDraft,
|
|
15
|
+
switchNeedsWarning,
|
|
16
|
+
} from "./compose-mode.js";
|
|
17
|
+
|
|
18
|
+
describe("the mode a draft reopens in", () => {
|
|
19
|
+
it("opens a draft with HTML as rich", () => {
|
|
20
|
+
assert.equal(modeOfDraft("<p>Hello</p>"), "rich");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("opens an empty rich document as rich", () => {
|
|
24
|
+
assert.equal(modeOfDraft("<p><br></p>"), "rich");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("opens a draft whose HTML was cleared as plain", () => {
|
|
28
|
+
assert.equal(modeOfDraft(""), "plain");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("opens a draft that never had HTML as plain", () => {
|
|
32
|
+
assert.equal(modeOfDraft(undefined), "plain");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("whether the switch warns first", () => {
|
|
37
|
+
it("warns when the document holds formatting", () => {
|
|
38
|
+
assert.equal(switchNeedsWarning("plain", ["table"]), true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("says nothing over plain paragraphs", () => {
|
|
42
|
+
assert.equal(switchNeedsWarning("plain", []), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("never warns on the way back to rich", () => {
|
|
46
|
+
assert.equal(switchNeedsWarning("rich", ["table", "bold"]), false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("a conversion that would empty a written message", () => {
|
|
51
|
+
it("goes ahead when the conversion carried the message across", () => {
|
|
52
|
+
assert.deepEqual(conversionOutcome("plain", "Due Friday.", "Due Friday."), {
|
|
53
|
+
outcome: "switch",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("goes ahead when there was nothing to carry", () => {
|
|
58
|
+
assert.deepEqual(conversionOutcome("plain", "", ""), { outcome: "switch" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("refuses, naming the direction, when plain text came back empty", () => {
|
|
62
|
+
assert.deepEqual(conversionOutcome("plain", "Due Friday.", " "), {
|
|
63
|
+
outcome: "blocked",
|
|
64
|
+
title: "Couldn't switch to plain text",
|
|
65
|
+
detail: "The conversion came back empty, so your message is unchanged.",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("refuses, naming the direction, when rich text came back empty", () => {
|
|
70
|
+
assert.deepEqual(conversionOutcome("rich", "Due Friday.", ""), {
|
|
71
|
+
outcome: "blocked",
|
|
72
|
+
title: "Couldn't switch to rich text",
|
|
73
|
+
detail: "The conversion came back empty, so your message is unchanged.",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ComposeBodyMode } from "../components/compose-mode-toggle.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which surface a draft reopens in, with no field of its own. `htmlBody` a
|
|
5
|
+
* non-empty string is a rich draft; anything else is plain.
|
|
6
|
+
*
|
|
7
|
+
* Not "falsy": the rich editor serializes an empty document to `<p><br></p>`,
|
|
8
|
+
* so a rich draft's `htmlBody` is never absent, and a plain draft clears the
|
|
9
|
+
* column to the empty string rather than omitting it — absent means "leave
|
|
10
|
+
* alone" at every layer below this one.
|
|
11
|
+
*/
|
|
12
|
+
export const modeOfDraft = (htmlBody: string | undefined): ComposeBodyMode =>
|
|
13
|
+
typeof htmlBody === "string" && htmlBody.length > 0 ? "rich" : "plain";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether switching to plain text destroys something. True for any node type or
|
|
17
|
+
* text format the document holds that plain text cannot carry.
|
|
18
|
+
*/
|
|
19
|
+
export const switchNeedsWarning = (
|
|
20
|
+
target: ComposeBodyMode,
|
|
21
|
+
formatting: readonly string[],
|
|
22
|
+
): boolean => target === "plain" && formatting.length > 0;
|
|
23
|
+
|
|
24
|
+
export type ConversionOutcome =
|
|
25
|
+
| { outcome: "switch" }
|
|
26
|
+
| { outcome: "blocked"; title: string; detail: string };
|
|
27
|
+
|
|
28
|
+
const BLOCKED_TITLES: Record<ComposeBodyMode, string> = {
|
|
29
|
+
plain: "Couldn't switch to plain text",
|
|
30
|
+
rich: "Couldn't switch to rich text",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A conversion that empties a written message does not happen. Autosave would
|
|
35
|
+
* persist the blank body a moment later, so the draft would be gone with
|
|
36
|
+
* nothing said. An empty body converting to an empty body is not this case.
|
|
37
|
+
*/
|
|
38
|
+
export const conversionOutcome = (
|
|
39
|
+
target: ComposeBodyMode,
|
|
40
|
+
source: string,
|
|
41
|
+
converted: string,
|
|
42
|
+
): ConversionOutcome => {
|
|
43
|
+
if (source.trim() === "" || converted.trim() !== "")
|
|
44
|
+
return { outcome: "switch" };
|
|
45
|
+
return {
|
|
46
|
+
outcome: "blocked",
|
|
47
|
+
title: BLOCKED_TITLES[target],
|
|
48
|
+
detail: "The conversion came back empty, so your message is unchanged.",
|
|
49
|
+
};
|
|
50
|
+
};
|
package/src/rich-text.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
* land in whichever chunk already imports `@remit/ui`, which is every screen.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
export {
|
|
8
|
+
ComposeBody,
|
|
9
|
+
type ComposeBodyProps,
|
|
10
|
+
type ComposeConversions,
|
|
11
|
+
type ConversionFailure,
|
|
12
|
+
DEFAULT_COMPOSE_CONVERSIONS,
|
|
13
|
+
} from "./components/compose-body.js";
|
|
7
14
|
export {
|
|
8
15
|
ComposeLanguageChip,
|
|
9
16
|
type ComposeLanguageChipProps,
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { ComposeActionBar } from "./compose-action-bar.js";
|
|
3
|
-
import { ComposeFormShell, composeModeLabels } from "./compose-form-shell.js";
|
|
4
|
-
|
|
5
|
-
const meta: Meta<typeof ComposeFormShell> = {
|
|
6
|
-
title: "Mail/ComposeForm",
|
|
7
|
-
component: ComposeFormShell,
|
|
8
|
-
parameters: { layout: "fullscreen" },
|
|
9
|
-
};
|
|
10
|
-
export default meta;
|
|
11
|
-
|
|
12
|
-
type Story = StoryObj<typeof ComposeFormShell>;
|
|
13
|
-
|
|
14
|
-
const Header = ({ title }: { title: string }) => (
|
|
15
|
-
<div className="space-y-1 border-b border-line px-3 py-2">
|
|
16
|
-
<div className="text-xs font-semibold text-fg-muted">{title}</div>
|
|
17
|
-
<div className="flex items-center gap-2 text-sm">
|
|
18
|
-
<span className="w-12 text-fg-muted">To</span>
|
|
19
|
-
<span>alex@example.com</span>
|
|
20
|
-
</div>
|
|
21
|
-
<div className="flex items-center gap-2 text-sm">
|
|
22
|
-
<span className="w-12 text-fg-muted">Subject</span>
|
|
23
|
-
<span>Q3 planning notes</span>
|
|
24
|
-
</div>
|
|
25
|
-
</div>
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
const Body = () => (
|
|
29
|
-
<div className="min-h-[120px] px-3 py-2 text-sm">
|
|
30
|
-
Hi Alex, here are the notes from today…
|
|
31
|
-
</div>
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
const bar = (
|
|
35
|
-
<ComposeActionBar
|
|
36
|
-
onSend={() => undefined}
|
|
37
|
-
onDiscard={() => undefined}
|
|
38
|
-
sending={false}
|
|
39
|
-
canSend={true}
|
|
40
|
-
saveStatus="saved"
|
|
41
|
-
/>
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
export const DesktopFull: Story = {
|
|
45
|
-
name: "Desktop — full compose",
|
|
46
|
-
render: () => (
|
|
47
|
-
<div className="h-[560px] w-[560px] border border-line bg-canvas">
|
|
48
|
-
<ComposeFormShell
|
|
49
|
-
header={<Header title={composeModeLabels.new} />}
|
|
50
|
-
actionBar={bar}
|
|
51
|
-
>
|
|
52
|
-
<Body />
|
|
53
|
-
</ComposeFormShell>
|
|
54
|
-
</div>
|
|
55
|
-
),
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
export const InlineReply: Story = {
|
|
59
|
-
name: "Inline reply",
|
|
60
|
-
render: () => (
|
|
61
|
-
<div className="flex h-[400px] w-[640px] max-h-[400px] flex-col border-t border-line bg-canvas">
|
|
62
|
-
<ComposeFormShell
|
|
63
|
-
header={<Header title={composeModeLabels.reply} />}
|
|
64
|
-
quoted={
|
|
65
|
-
<div className="border-l-2 border-line pl-3 text-xs text-fg-muted">
|
|
66
|
-
On Tuesday, Alex wrote: …
|
|
67
|
-
</div>
|
|
68
|
-
}
|
|
69
|
-
actionBar={bar}
|
|
70
|
-
>
|
|
71
|
-
<Body />
|
|
72
|
-
</ComposeFormShell>
|
|
73
|
-
</div>
|
|
74
|
-
),
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export const MobileSheet: Story = {
|
|
78
|
-
name: "Mobile sheet — Send within viewport",
|
|
79
|
-
globals: { viewport: { value: "mobile" } },
|
|
80
|
-
render: () => (
|
|
81
|
-
<div className="flex h-[95dvh] w-[390px] flex-col rounded-t-lg border border-line bg-canvas">
|
|
82
|
-
<div className="mx-auto mt-2 mb-1 h-1.5 w-12 rounded-full bg-fg-muted/30" />
|
|
83
|
-
<div className="border-b border-line px-4 py-2 text-base font-semibold">
|
|
84
|
-
{composeModeLabels.new}
|
|
85
|
-
</div>
|
|
86
|
-
<div className="min-h-0 flex-1">
|
|
87
|
-
<ComposeFormShell
|
|
88
|
-
header={<Header title={composeModeLabels.new} />}
|
|
89
|
-
actionBar={bar}
|
|
90
|
-
>
|
|
91
|
-
<Body />
|
|
92
|
-
</ComposeFormShell>
|
|
93
|
-
</div>
|
|
94
|
-
</div>
|
|
95
|
-
),
|
|
96
|
-
};
|