@remit/ui 0.0.101 → 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 +2 -1
- package/src/components/button.tsx +4 -0
- 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-language-chip.stories.tsx +136 -0
- package/src/components/compose-language-chip.tsx +151 -0
- package/src/components/compose-language-setting.stories.tsx +82 -0
- package/src/components/compose-language-setting.tsx +106 -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/components/plain-text-editor.stories.tsx +14 -4
- package/src/components/plain-text-editor.tsx +13 -1
- package/src/components/rich-text-editor.stories.tsx +36 -12
- package/src/components/rich-text-editor.tsx +8 -0
- package/src/components/rich-text-toolbar.tsx +8 -4
- package/src/components/use-compose-language.ts +86 -0
- package/src/index.ts +39 -0
- package/src/lib/adopted-html.test.ts +9 -0
- package/src/lib/adopted-html.ts +13 -1
- package/src/lib/compose-language.test.ts +193 -0
- package/src/lib/compose-language.ts +206 -0
- package/src/lib/compose-mode.test.ts +76 -0
- package/src/lib/compose-mode.ts +50 -0
- package/src/lib/detect-compose-language.test.ts +51 -0
- package/src/lib/detect-compose-language.ts +42 -0
- package/src/rich-text.ts +22 -0
- package/src/components/compose-form-shell.stories.tsx +0 -96
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection restricted to the account's own languages. The restriction is the
|
|
3
|
+
* whole reason a 53 kB trigram table is good enough: `franc` scores 87.1% on
|
|
4
|
+
* one sentence over all 414 languages and 97.3% over six.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { describe, it } from "node:test";
|
|
8
|
+
import { detectComposeLanguage } from "./detect-compose-language.js";
|
|
9
|
+
|
|
10
|
+
const DUTCH =
|
|
11
|
+
"Beste Anna, de vergadering van donderdag gaat niet door. Ik stuur je morgen een nieuw voorstel.";
|
|
12
|
+
const ENGLISH =
|
|
13
|
+
"Hi Anna, Thursday's meeting is off. I will send you a new proposal tomorrow.";
|
|
14
|
+
const GERMAN =
|
|
15
|
+
"Hallo Anna, die Besprechung am Donnerstag fällt aus. Ich schicke dir morgen einen neuen Vorschlag.";
|
|
16
|
+
const FRENCH =
|
|
17
|
+
"Bonjour Anna, la réunion de jeudi est annulée. Je vous enverrai une nouvelle proposition demain.";
|
|
18
|
+
|
|
19
|
+
const CANDIDATES = ["nl", "en", "de"];
|
|
20
|
+
|
|
21
|
+
describe("detectComposeLanguage", () => {
|
|
22
|
+
it("picks each candidate out of the others", () => {
|
|
23
|
+
assert.equal(detectComposeLanguage(DUTCH, CANDIDATES), "nl");
|
|
24
|
+
assert.equal(detectComposeLanguage(ENGLISH, CANDIDATES), "en");
|
|
25
|
+
assert.equal(detectComposeLanguage(GERMAN, CANDIDATES), "de");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("stays inside the candidate set", () => {
|
|
29
|
+
const detected = detectComposeLanguage(FRENCH, CANDIDATES);
|
|
30
|
+
assert.ok(detected === null || CANDIDATES.includes(detected));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("declines a greeting line", () => {
|
|
34
|
+
assert.equal(detectComposeLanguage("Hoi Anna,", CANDIDATES), null);
|
|
35
|
+
assert.equal(detectComposeLanguage(" ", CANDIDATES), null);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("declines when there is nothing to choose between", () => {
|
|
39
|
+
assert.equal(detectComposeLanguage(DUTCH, ["nl"]), null);
|
|
40
|
+
assert.equal(detectComposeLanguage(DUTCH, []), null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("ignores a configured language nothing can detect", () => {
|
|
44
|
+
assert.equal(detectComposeLanguage(DUTCH, ["nl", "ja"]), null);
|
|
45
|
+
assert.equal(detectComposeLanguage(DUTCH, ["nl", "ja", "en"]), "nl");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("resolves a regional tag through its language", () => {
|
|
49
|
+
assert.equal(detectComposeLanguage(ENGLISH, ["nl", "en-GB"]), "en-GB");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { franc } from "franc-min";
|
|
2
|
+
import { detectionCodeFor } from "./compose-language.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Below this, detection is a coin toss — `franc-min` scores 90.7% at 20 to 45
|
|
6
|
+
* characters and drops off a cliff under that. A greeting line holds the
|
|
7
|
+
* account default instead.
|
|
8
|
+
*/
|
|
9
|
+
export const DETECTION_MIN_LENGTH = 20;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The language of a body, chosen inside the account's own candidate set.
|
|
13
|
+
*
|
|
14
|
+
* The restriction is what makes a 53 kB trigram table good enough: `franc`
|
|
15
|
+
* scores 87.1% on one sentence over all 414 languages and 97.3% over six.
|
|
16
|
+
* Returns null when the text is too short, when there is nothing to choose
|
|
17
|
+
* between, or when the detector declines to answer.
|
|
18
|
+
*
|
|
19
|
+
* Kept apart from the hook that calls it so `franc-min` stays out of every
|
|
20
|
+
* chunk that only wants a language tag, and so this is testable as the pure
|
|
21
|
+
* function it is.
|
|
22
|
+
*/
|
|
23
|
+
export const detectComposeLanguage = (
|
|
24
|
+
text: string,
|
|
25
|
+
candidates: readonly string[],
|
|
26
|
+
): string | null => {
|
|
27
|
+
const trimmed = text.trim();
|
|
28
|
+
if (trimmed.length < DETECTION_MIN_LENGTH) return null;
|
|
29
|
+
|
|
30
|
+
const tagByCode = new Map<string, string>();
|
|
31
|
+
for (const tag of candidates) {
|
|
32
|
+
const code = detectionCodeFor(tag);
|
|
33
|
+
if (code && !tagByCode.has(code)) tagByCode.set(code, tag);
|
|
34
|
+
}
|
|
35
|
+
if (tagByCode.size < 2) return null;
|
|
36
|
+
|
|
37
|
+
const detected = franc(trimmed, {
|
|
38
|
+
only: [...tagByCode.keys()],
|
|
39
|
+
minLength: DETECTION_MIN_LENGTH,
|
|
40
|
+
});
|
|
41
|
+
return tagByCode.get(detected) ?? null;
|
|
42
|
+
};
|
package/src/rich-text.ts
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
* app can load them on demand: reached through the package barrel they would
|
|
4
4
|
* land in whichever chunk already imports `@remit/ui`, which is every screen.
|
|
5
5
|
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
ComposeBody,
|
|
9
|
+
type ComposeBodyProps,
|
|
10
|
+
type ComposeConversions,
|
|
11
|
+
type ConversionFailure,
|
|
12
|
+
DEFAULT_COMPOSE_CONVERSIONS,
|
|
13
|
+
} from "./components/compose-body.js";
|
|
14
|
+
export {
|
|
15
|
+
ComposeLanguageChip,
|
|
16
|
+
type ComposeLanguageChipProps,
|
|
17
|
+
} from "./components/compose-language-chip.js";
|
|
6
18
|
export {
|
|
7
19
|
type ComposeBodyMode,
|
|
8
20
|
ComposeModeToggle,
|
|
@@ -25,3 +37,13 @@ export {
|
|
|
25
37
|
EMPTY_RICH_TEXT,
|
|
26
38
|
type RichTextValue,
|
|
27
39
|
} from "./components/rich-text-value.js";
|
|
40
|
+
export {
|
|
41
|
+
type ComposeLanguageControl,
|
|
42
|
+
type ComposeLanguageSource,
|
|
43
|
+
type ComposeLanguageState,
|
|
44
|
+
useComposeLanguage,
|
|
45
|
+
} from "./components/use-compose-language.js";
|
|
46
|
+
export {
|
|
47
|
+
DETECTION_MIN_LENGTH,
|
|
48
|
+
detectComposeLanguage,
|
|
49
|
+
} from "./lib/detect-compose-language.js";
|
|
@@ -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
|
-
};
|