@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,106 @@
|
|
|
1
|
+
import { Star, X } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
COMPOSE_LANGUAGE_OPTIONS,
|
|
4
|
+
languageLabel,
|
|
5
|
+
primaryLanguageSubtag,
|
|
6
|
+
} from "../lib/compose-language.js";
|
|
7
|
+
import { Button } from "./button.js";
|
|
8
|
+
|
|
9
|
+
export interface ComposeLanguageSettingProps {
|
|
10
|
+
/** The account's tags, most-used first. The first is the compose default. */
|
|
11
|
+
value: readonly string[];
|
|
12
|
+
onChange: (languages: string[]) => void;
|
|
13
|
+
busy?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const addable = (chosen: readonly string[]): string[] => {
|
|
17
|
+
const taken = new Set(chosen.map(primaryLanguageSubtag));
|
|
18
|
+
return COMPOSE_LANGUAGE_OPTIONS.map((option) => option.tag)
|
|
19
|
+
.filter((tag) => !taken.has(tag))
|
|
20
|
+
.sort((left, right) =>
|
|
21
|
+
languageLabel(left).localeCompare(languageLabel(right)),
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The account's writing languages. The list is two things at once: the menu the
|
|
27
|
+
* composer's language chip offers, and the set detection is allowed to choose
|
|
28
|
+
* inside — which is what keeps detection accurate on one sentence. The first
|
|
29
|
+
* entry is what a new message opens on.
|
|
30
|
+
*/
|
|
31
|
+
export const ComposeLanguageSetting = ({
|
|
32
|
+
value,
|
|
33
|
+
onChange,
|
|
34
|
+
busy = false,
|
|
35
|
+
}: ComposeLanguageSettingProps) => {
|
|
36
|
+
const options = addable(value);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="space-y-2" data-testid="compose-language-setting">
|
|
40
|
+
<ul className="space-y-1">
|
|
41
|
+
{value.map((tag, index) => (
|
|
42
|
+
<li
|
|
43
|
+
key={tag}
|
|
44
|
+
data-testid={`compose-language-row-${tag}`}
|
|
45
|
+
className="flex items-center gap-2 rounded-md border border-line bg-surface-sunken px-3 py-1.5"
|
|
46
|
+
>
|
|
47
|
+
<span
|
|
48
|
+
className="min-w-0 flex-1 truncate text-sm text-fg"
|
|
49
|
+
lang={tag}
|
|
50
|
+
>
|
|
51
|
+
{languageLabel(tag)}
|
|
52
|
+
</span>
|
|
53
|
+
{index === 0 ? (
|
|
54
|
+
<span className="shrink-0 text-2xs uppercase tracking-wider text-fg-subtle">
|
|
55
|
+
Default
|
|
56
|
+
</span>
|
|
57
|
+
) : (
|
|
58
|
+
<Button
|
|
59
|
+
variant="ghost"
|
|
60
|
+
size="sm"
|
|
61
|
+
icon={<Star className="size-3.5" />}
|
|
62
|
+
disabled={busy}
|
|
63
|
+
aria-label={`Write new messages in ${languageLabel(tag)} by default`}
|
|
64
|
+
onClick={() =>
|
|
65
|
+
onChange([tag, ...value.filter((other) => other !== tag)])
|
|
66
|
+
}
|
|
67
|
+
>
|
|
68
|
+
Default
|
|
69
|
+
</Button>
|
|
70
|
+
)}
|
|
71
|
+
<Button
|
|
72
|
+
variant="ghost"
|
|
73
|
+
size="sm"
|
|
74
|
+
icon={<X className="size-3.5" />}
|
|
75
|
+
disabled={busy || value.length === 1}
|
|
76
|
+
aria-label={`Remove ${languageLabel(tag)}`}
|
|
77
|
+
onClick={() => onChange(value.filter((other) => other !== tag))}
|
|
78
|
+
/>
|
|
79
|
+
</li>
|
|
80
|
+
))}
|
|
81
|
+
</ul>
|
|
82
|
+
<select
|
|
83
|
+
aria-label="Add a language"
|
|
84
|
+
value=""
|
|
85
|
+
disabled={busy || options.length === 0}
|
|
86
|
+
onChange={(event) => {
|
|
87
|
+
if (event.target.value === "") return;
|
|
88
|
+
onChange([...value, event.target.value]);
|
|
89
|
+
}}
|
|
90
|
+
className="w-full rounded-md border border-line bg-surface-sunken px-3 py-2 text-sm text-fg outline-none transition-colors focus-within:border-line-strong focus-within:ring-2 focus-within:ring-ring/30"
|
|
91
|
+
>
|
|
92
|
+
<option value="">Add a language…</option>
|
|
93
|
+
{options.map((tag) => (
|
|
94
|
+
<option key={tag} value={tag}>
|
|
95
|
+
{languageLabel(tag)}
|
|
96
|
+
</option>
|
|
97
|
+
))}
|
|
98
|
+
</select>
|
|
99
|
+
<p className="text-xs text-fg-muted">
|
|
100
|
+
A message is tagged with the language you write it in, picked from this
|
|
101
|
+
list. Your browser, not this app, decides which dictionaries it
|
|
102
|
+
spellchecks against — add the language there too to get its underlines.
|
|
103
|
+
</p>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
4
|
+
import {
|
|
5
|
+
type ComposeBodyMode,
|
|
6
|
+
ComposeModeToggle,
|
|
7
|
+
} from "./compose-mode-toggle.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The control that swaps the writing surface. It reads "Plain text" in both
|
|
11
|
+
* states — the label names the mode it offers, and `aria-pressed` carries
|
|
12
|
+
* which one is up, so the control never changes under the finger.
|
|
13
|
+
*/
|
|
14
|
+
const meta: Meta<typeof ComposeModeToggle> = {
|
|
15
|
+
title: "Mail/ComposeModeToggle",
|
|
16
|
+
component: ComposeModeToggle,
|
|
17
|
+
parameters: { layout: "centered" },
|
|
18
|
+
args: { onToggle: () => undefined },
|
|
19
|
+
};
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof ComposeModeToggle>;
|
|
23
|
+
|
|
24
|
+
export const RichText: Story = {
|
|
25
|
+
name: "Rich text — plain text on offer",
|
|
26
|
+
args: { mode: "rich" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const PlainText: Story = {
|
|
30
|
+
name: "Plain text — the mode is on",
|
|
31
|
+
args: { mode: "plain" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const Harness = ({ start }: { start: ComposeBodyMode }) => {
|
|
35
|
+
const [mode, setMode] = useState<ComposeBodyMode>(start);
|
|
36
|
+
return (
|
|
37
|
+
<ComposeModeToggle
|
|
38
|
+
mode={mode}
|
|
39
|
+
onToggle={() => setMode(mode === "plain" ? "rich" : "plain")}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const PressedStateFollowsTheMode: Story = {
|
|
45
|
+
render: () => <Harness start="rich" />,
|
|
46
|
+
play: async ({ canvasElement }) => {
|
|
47
|
+
const toggle = within(canvasElement).getByTestId("compose-mode-toggle");
|
|
48
|
+
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
49
|
+
await userEvent.click(toggle);
|
|
50
|
+
await expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
51
|
+
await expect(toggle).toHaveTextContent("Plain text");
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { expect, fn, userEvent, within } from "storybook/test";
|
|
3
|
+
import { ComposeSmtpMissingBanner } from "./compose-smtp-missing-banner.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The selected account has no SMTP host. Send stays pressable and explains
|
|
7
|
+
* itself; this says the same thing before the user reaches for it, and carries
|
|
8
|
+
* the way out rather than leaving them to find Settings.
|
|
9
|
+
*/
|
|
10
|
+
const meta: Meta<typeof ComposeSmtpMissingBanner> = {
|
|
11
|
+
title: "Mail/ComposeSmtpMissingBanner",
|
|
12
|
+
component: ComposeSmtpMissingBanner,
|
|
13
|
+
parameters: { layout: "padded" },
|
|
14
|
+
args: { onConfigure: () => undefined },
|
|
15
|
+
};
|
|
16
|
+
export default meta;
|
|
17
|
+
|
|
18
|
+
type Story = StoryObj<typeof ComposeSmtpMissingBanner>;
|
|
19
|
+
|
|
20
|
+
export const Default: Story = {};
|
|
21
|
+
|
|
22
|
+
export const TheWayOutIsReachable: Story = {
|
|
23
|
+
args: { onConfigure: fn() },
|
|
24
|
+
play: async ({ args, canvasElement }) => {
|
|
25
|
+
await userEvent.click(
|
|
26
|
+
within(canvasElement).getByRole("button", { name: /Configure SMTP/ }),
|
|
27
|
+
);
|
|
28
|
+
await expect(args.onConfigure).toHaveBeenCalled();
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { AlertTriangle, ArrowRight } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
export interface ComposeSmtpMissingBannerProps {
|
|
4
|
+
onConfigure: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Non-dismissible banner shown above the compose form when the selected
|
|
9
|
+
* account has no SMTP host configured. Pairs with a Send button that explains
|
|
10
|
+
* rather than disables, so the user has a single, factual account of why
|
|
11
|
+
* nothing will leave. See issue #196.
|
|
12
|
+
*/
|
|
13
|
+
export const ComposeSmtpMissingBanner = ({
|
|
14
|
+
onConfigure,
|
|
15
|
+
}: ComposeSmtpMissingBannerProps) => (
|
|
16
|
+
<div
|
|
17
|
+
role="alert"
|
|
18
|
+
data-testid="compose-smtp-missing-banner"
|
|
19
|
+
className="flex items-start gap-3 border-b border-warning/50 bg-warning/10 px-3 py-2"
|
|
20
|
+
>
|
|
21
|
+
<AlertTriangle
|
|
22
|
+
className="size-5 shrink-0 mt-0.5 text-warning"
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
/>
|
|
25
|
+
<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>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={onConfigure}
|
|
32
|
+
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-warning hover:underline"
|
|
33
|
+
>
|
|
34
|
+
Configure SMTP
|
|
35
|
+
<ArrowRight className="size-3" aria-hidden="true" />
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ComposeSubjectFieldProps {
|
|
2
|
+
value: string;
|
|
3
|
+
onChange: (value: string) => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const ComposeSubjectField = ({
|
|
7
|
+
value,
|
|
8
|
+
onChange,
|
|
9
|
+
}: ComposeSubjectFieldProps) => (
|
|
10
|
+
<div className="flex items-start gap-2">
|
|
11
|
+
{/* biome-ignore lint/a11y/noLabelWithoutControl: label is visually adjacent to the sibling input; static id risks duplicates */}
|
|
12
|
+
<label className="text-sm text-fg-muted shrink-0 w-12 pt-1.5">Subj:</label>
|
|
13
|
+
<input
|
|
14
|
+
type="text"
|
|
15
|
+
value={value}
|
|
16
|
+
onChange={(e) => onChange(e.target.value)}
|
|
17
|
+
className="flex-1 px-2 py-1.5 border rounded-md bg-canvas text-sm"
|
|
18
|
+
placeholder="Subject"
|
|
19
|
+
data-subject-field
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ConfirmDialog } from "./confirm-dialog.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ConfirmDialog> = {
|
|
5
|
+
title: "Primitives/ConfirmDialog",
|
|
6
|
+
component: ConfirmDialog,
|
|
7
|
+
parameters: { layout: "centered" },
|
|
8
|
+
args: {
|
|
9
|
+
isOpen: true,
|
|
10
|
+
title: "Move 3,412 messages to Trash?",
|
|
11
|
+
description: "You can restore them from Trash later.",
|
|
12
|
+
confirmLabel: "Move to Trash",
|
|
13
|
+
destructive: true,
|
|
14
|
+
onConfirm: () => undefined,
|
|
15
|
+
onCancel: () => undefined,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
export default meta;
|
|
19
|
+
|
|
20
|
+
type Story = StoryObj<typeof ConfirmDialog>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A single corner tap on the bar's delete icon used to fall straight through
|
|
24
|
+
* to a delete with nothing in between — this is what now sits in the way.
|
|
25
|
+
* Wording says "Move … to Trash", not "Delete": the operation is reversible
|
|
26
|
+
* (IMAP delete moves to Trash), and the confirmation copy says so rather than
|
|
27
|
+
* reading as final.
|
|
28
|
+
*/
|
|
29
|
+
export const Default: Story = {};
|
|
30
|
+
|
|
31
|
+
export const OneMessage: Story = {
|
|
32
|
+
args: {
|
|
33
|
+
title: "Move 1 message to Trash?",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The mutation is in flight: the confirm button disables rather than
|
|
38
|
+
* allowing a second concurrent delete request. */
|
|
39
|
+
export const Busy: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
isBusy: true,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A non-destructive confirmation (no `destructive`) uses the accent
|
|
46
|
+
* affirmative styling instead of danger. */
|
|
47
|
+
export const NonDestructive: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
title: "Archive 12 messages?",
|
|
50
|
+
description: undefined,
|
|
51
|
+
confirmLabel: "Archive",
|
|
52
|
+
destructive: false,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export interface ConfirmDialogProps {
|
|
5
|
+
isOpen: boolean;
|
|
6
|
+
title: string;
|
|
7
|
+
/** Optional supporting line under the title. */
|
|
8
|
+
description?: string;
|
|
9
|
+
confirmLabel: string;
|
|
10
|
+
cancelLabel?: string;
|
|
11
|
+
/** Style the confirm button as a destructive action. */
|
|
12
|
+
destructive?: boolean;
|
|
13
|
+
/** Disable the confirm button (e.g. while a mutation is in flight). */
|
|
14
|
+
isBusy?: boolean;
|
|
15
|
+
onConfirm: () => void;
|
|
16
|
+
onCancel: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimal accessible confirmation dialog. No existing Dialog/ConfirmDialog
|
|
21
|
+
* primitive ships in the web client (only the bespoke KeyboardShortcutsModal
|
|
22
|
+
* and SlidePanel), so this is a small reusable one matching their Tailwind +
|
|
23
|
+
* overlay conventions. Esc cancels, the backdrop cancels, Cancel is focused on
|
|
24
|
+
* open, and the confirm/cancel pair is the only focusable content so the focus
|
|
25
|
+
* stays within the dialog.
|
|
26
|
+
*/
|
|
27
|
+
export const ConfirmDialog = ({
|
|
28
|
+
isOpen,
|
|
29
|
+
title,
|
|
30
|
+
description,
|
|
31
|
+
confirmLabel,
|
|
32
|
+
cancelLabel = "Cancel",
|
|
33
|
+
destructive = false,
|
|
34
|
+
isBusy = false,
|
|
35
|
+
onConfirm,
|
|
36
|
+
onCancel,
|
|
37
|
+
}: ConfirmDialogProps) => {
|
|
38
|
+
const cancelRef = useRef<HTMLButtonElement>(null);
|
|
39
|
+
|
|
40
|
+
const handleKeyDown = useCallback(
|
|
41
|
+
(event: KeyboardEvent) => {
|
|
42
|
+
if (event.key === "Escape") {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
event.stopPropagation();
|
|
45
|
+
event.stopImmediatePropagation();
|
|
46
|
+
onCancel();
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
[onCancel],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!isOpen) return;
|
|
54
|
+
// Capture phase so Esc closes the dialog before any list-level Esc
|
|
55
|
+
// handler (e.g. clearSelection) also fires on the same keystroke.
|
|
56
|
+
window.addEventListener("keydown", handleKeyDown, true);
|
|
57
|
+
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
58
|
+
}, [isOpen, handleKeyDown]);
|
|
59
|
+
|
|
60
|
+
// Whoever opened the dialog gets the focus back when it closes. Without this
|
|
61
|
+
// a cancelled confirmation drops focus to the body, and the control the user
|
|
62
|
+
// was on — the compose mode toggle, a row's delete button — is gone from
|
|
63
|
+
// under the keyboard.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!isOpen) return;
|
|
66
|
+
const opener =
|
|
67
|
+
document.activeElement instanceof HTMLElement
|
|
68
|
+
? document.activeElement
|
|
69
|
+
: null;
|
|
70
|
+
cancelRef.current?.focus();
|
|
71
|
+
return () => {
|
|
72
|
+
if (opener?.isConnected) opener.focus();
|
|
73
|
+
};
|
|
74
|
+
}, [isOpen]);
|
|
75
|
+
|
|
76
|
+
if (!isOpen) return null;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
// Above every other overlay, the mobile compose sheet included: a
|
|
80
|
+
// confirmation is the decision blocking whatever is under it, and a drawer
|
|
81
|
+
// portalled to the body at the same level would cover it.
|
|
82
|
+
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
|
83
|
+
{/* Backdrop. It carries the click-to-dismiss and the aria-hidden: the
|
|
84
|
+
dialog itself must stay in the accessibility tree, and an
|
|
85
|
+
aria-hidden ancestor would take it out. */}
|
|
86
|
+
<div
|
|
87
|
+
className="absolute inset-0 bg-canvas/80 backdrop-blur-sm"
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
onClick={onCancel}
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
{/* Dialog */}
|
|
93
|
+
<div
|
|
94
|
+
role="dialog"
|
|
95
|
+
aria-modal="true"
|
|
96
|
+
aria-label={title}
|
|
97
|
+
className={cn(
|
|
98
|
+
"relative z-10 w-full max-w-sm",
|
|
99
|
+
"bg-surface border border-line rounded-sm shadow-lg",
|
|
100
|
+
"p-6",
|
|
101
|
+
)}
|
|
102
|
+
onClick={(e) => e.stopPropagation()}
|
|
103
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
104
|
+
>
|
|
105
|
+
<h2 className="text-lg font-semibold">{title}</h2>
|
|
106
|
+
{description && (
|
|
107
|
+
<p className="mt-2 text-sm text-fg-muted">{description}</p>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<div className="mt-6 flex items-center justify-end gap-2">
|
|
111
|
+
<button
|
|
112
|
+
ref={cancelRef}
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={onCancel}
|
|
115
|
+
className={cn(
|
|
116
|
+
"min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
|
|
117
|
+
"border border-line hover:bg-surface-raised",
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{cancelLabel}
|
|
121
|
+
</button>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={onConfirm}
|
|
125
|
+
disabled={isBusy}
|
|
126
|
+
className={cn(
|
|
127
|
+
"min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
|
|
128
|
+
destructive
|
|
129
|
+
? "bg-danger text-canvas hover:bg-danger/90"
|
|
130
|
+
: "bg-accent text-accent-fg hover:bg-accent-hover",
|
|
131
|
+
"disabled:opacity-50 disabled:cursor-not-allowed",
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
{confirmLabel}
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { expect, fn, userEvent } from "storybook/test";
|
|
4
|
+
import { ComposeLanguageChip } from "./compose-language-chip.js";
|
|
4
5
|
import { ComposeModeToggle } from "./compose-mode-toggle.js";
|
|
5
6
|
import { PlainTextEditor } from "./plain-text-editor.js";
|
|
6
7
|
|
|
@@ -37,16 +38,25 @@ const Surface = ({
|
|
|
37
38
|
}) => {
|
|
38
39
|
const [text, setText] = useState(initial);
|
|
39
40
|
const [mode, setMode] = useState<"rich" | "plain">("plain");
|
|
41
|
+
const [language, setLanguage] = useState("nl");
|
|
40
42
|
return (
|
|
41
43
|
<PlainTextEditor
|
|
42
44
|
value={text}
|
|
43
45
|
onChange={setText}
|
|
44
46
|
onSubmit={onSubmit}
|
|
47
|
+
lang={language}
|
|
45
48
|
trailing={
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
<>
|
|
50
|
+
<ComposeLanguageChip
|
|
51
|
+
language={language}
|
|
52
|
+
languages={["nl", "en", "de"]}
|
|
53
|
+
onSelect={setLanguage}
|
|
54
|
+
/>
|
|
55
|
+
<ComposeModeToggle
|
|
56
|
+
mode={mode}
|
|
57
|
+
onToggle={() => setMode(mode === "plain" ? "rich" : "plain")}
|
|
58
|
+
/>
|
|
59
|
+
</>
|
|
50
60
|
}
|
|
51
61
|
/>
|
|
52
62
|
);
|
|
@@ -20,6 +20,12 @@ export interface PlainTextEditorProps {
|
|
|
20
20
|
ariaLabel?: string;
|
|
21
21
|
/** Pinned to the right of the toolbar strip. The mode toggle rides here. */
|
|
22
22
|
trailing?: ReactNode;
|
|
23
|
+
/**
|
|
24
|
+
* BCP 47 tag of the language the message is being written in. Firefox picks
|
|
25
|
+
* a dictionary from it among the ones the user installed; Chrome and Safari
|
|
26
|
+
* ignore it. Every screen reader picks a voice from it.
|
|
27
|
+
*/
|
|
28
|
+
lang?: string;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
const EMPTY_PASTE_NOTICE =
|
|
@@ -58,6 +64,7 @@ export const PlainTextEditor = ({
|
|
|
58
64
|
placeholder = "Write your message…",
|
|
59
65
|
ariaLabel = "Message body",
|
|
60
66
|
trailing,
|
|
67
|
+
lang,
|
|
61
68
|
}: PlainTextEditorProps) => {
|
|
62
69
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
63
70
|
const pendingCaret = useRef<number | null>(null);
|
|
@@ -141,7 +148,11 @@ export const PlainTextEditor = ({
|
|
|
141
148
|
<span className="min-w-0 truncate py-2 text-xs text-fg-muted">
|
|
142
149
|
Plain text · Markdown
|
|
143
150
|
</span>
|
|
144
|
-
{trailing &&
|
|
151
|
+
{trailing && (
|
|
152
|
+
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
153
|
+
{trailing}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
145
156
|
</div>
|
|
146
157
|
</div>
|
|
147
158
|
{emptyPaste && (
|
|
@@ -166,6 +177,7 @@ export const PlainTextEditor = ({
|
|
|
166
177
|
onChange={(event) => onChange(event.target.value)}
|
|
167
178
|
onKeyDown={handleKeyDown}
|
|
168
179
|
onPaste={handlePaste}
|
|
180
|
+
lang={lang}
|
|
169
181
|
aria-label={ariaLabel}
|
|
170
182
|
placeholder={placeholder}
|
|
171
183
|
data-testid="compose-body-plain"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { expect, userEvent } from "storybook/test";
|
|
3
3
|
import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
|
|
4
|
+
import { ComposeLanguageChip } from "./compose-language-chip.js";
|
|
4
5
|
import { ComposeModeToggle } from "./compose-mode-toggle.js";
|
|
5
6
|
import { RichTextEditor } from "./rich-text-editor.js";
|
|
6
7
|
|
|
@@ -104,23 +105,37 @@ export const ClickBelowTheText: Story = {
|
|
|
104
105
|
},
|
|
105
106
|
};
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
/**
|
|
109
|
+
* The two pinned controls, in the order compose ships them: the chip first, so
|
|
110
|
+
* one Shift+Tab out of the body still reaches the mode toggle and two reach the
|
|
111
|
+
* chip.
|
|
112
|
+
*/
|
|
113
|
+
const pinnedControls = (
|
|
114
|
+
<>
|
|
115
|
+
<ComposeLanguageChip
|
|
116
|
+
language="nl"
|
|
117
|
+
languages={["nl", "en", "de"]}
|
|
118
|
+
onSelect={() => undefined}
|
|
119
|
+
/>
|
|
120
|
+
<ComposeModeToggle mode="rich" onToggle={() => undefined} />
|
|
121
|
+
</>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/** The toolbar as compose ships it: the formatting cluster, then the two pinned controls. */
|
|
110
125
|
export const ToolbarInRich: Story = {
|
|
111
|
-
name: "Toolbar with the mode toggle",
|
|
112
|
-
args: { initialHtml: RICH_DOCUMENT, trailing:
|
|
126
|
+
name: "Toolbar with the language chip and the mode toggle",
|
|
127
|
+
args: { initialHtml: RICH_DOCUMENT, lang: "nl", trailing: pinnedControls },
|
|
113
128
|
};
|
|
114
129
|
|
|
115
130
|
/**
|
|
116
131
|
* At 390 the formatting cluster runs out of room. It scrolls inside its own
|
|
117
|
-
* strip and
|
|
118
|
-
* pushing
|
|
119
|
-
* `min-w-0` does.
|
|
132
|
+
* strip and both pinned controls stay at the right edge, rather than the
|
|
133
|
+
* cluster pushing them off the screen — which is what a flex child without
|
|
134
|
+
* `min-w-0` does. Two letters is what makes room for a second pinned item here.
|
|
120
135
|
*/
|
|
121
136
|
export const NarrowToolbar: Story = {
|
|
122
137
|
name: "Toolbar at 390",
|
|
123
|
-
args: { initialHtml: RICH_DOCUMENT, trailing:
|
|
138
|
+
args: { initialHtml: RICH_DOCUMENT, lang: "nl", trailing: pinnedControls },
|
|
124
139
|
decorators: [
|
|
125
140
|
(Story) => (
|
|
126
141
|
<div
|
|
@@ -141,13 +156,21 @@ export const NarrowToolbar: Story = {
|
|
|
141
156
|
const toggle = canvasElement.querySelector<HTMLElement>(
|
|
142
157
|
"[data-testid=compose-mode-toggle]",
|
|
143
158
|
);
|
|
144
|
-
|
|
159
|
+
const chip = canvasElement.querySelector<HTMLElement>(
|
|
160
|
+
"[data-testid=compose-language-chip]",
|
|
161
|
+
);
|
|
162
|
+
if (!frame || !cluster || !toggle || !chip)
|
|
145
163
|
throw new Error("the toolbar is not mounted");
|
|
146
164
|
|
|
147
165
|
await expect(cluster.scrollWidth).toBeGreaterThan(cluster.clientWidth);
|
|
166
|
+
const edge = frame.getBoundingClientRect().right + 1;
|
|
148
167
|
await expect(toggle.getBoundingClientRect().right).toBeLessThanOrEqual(
|
|
149
|
-
|
|
168
|
+
edge,
|
|
169
|
+
);
|
|
170
|
+
await expect(chip.getBoundingClientRect().left).toBeGreaterThanOrEqual(
|
|
171
|
+
frame.getBoundingClientRect().left,
|
|
150
172
|
);
|
|
173
|
+
await expect(chip.getBoundingClientRect().right).toBeLessThanOrEqual(edge);
|
|
151
174
|
},
|
|
152
175
|
};
|
|
153
176
|
|
|
@@ -160,7 +183,8 @@ export const StickyToolbar: Story = {
|
|
|
160
183
|
name: "Toolbar over a scrolled body",
|
|
161
184
|
args: {
|
|
162
185
|
initialHtml: `${RICH_DOCUMENT}${"<p>Another line of the message.</p>".repeat(30)}`,
|
|
163
|
-
|
|
186
|
+
lang: "nl",
|
|
187
|
+
trailing: pinnedControls,
|
|
164
188
|
},
|
|
165
189
|
play: async ({ canvasElement }) => {
|
|
166
190
|
const frame = canvasElement.querySelector<HTMLElement>(
|
|
@@ -38,6 +38,12 @@ export interface RichTextEditorProps {
|
|
|
38
38
|
ariaLabel?: string;
|
|
39
39
|
/** Pinned to the right of the toolbar strip. The mode toggle rides here. */
|
|
40
40
|
trailing?: React.ReactNode;
|
|
41
|
+
/**
|
|
42
|
+
* BCP 47 tag of the language the message is being written in. Firefox picks
|
|
43
|
+
* a dictionary from it among the ones the user installed; Chrome and Safari
|
|
44
|
+
* ignore it. Every screen reader picks a voice from it.
|
|
45
|
+
*/
|
|
46
|
+
lang?: string;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/**
|
|
@@ -164,6 +170,7 @@ export const RichTextEditor = ({
|
|
|
164
170
|
placeholder = "Write your message…",
|
|
165
171
|
ariaLabel = "Message body",
|
|
166
172
|
trailing,
|
|
173
|
+
lang,
|
|
167
174
|
}: RichTextEditorProps) => (
|
|
168
175
|
<LexicalComposer
|
|
169
176
|
initialConfig={{
|
|
@@ -185,6 +192,7 @@ export const RichTextEditor = ({
|
|
|
185
192
|
<RichTextPlugin
|
|
186
193
|
contentEditable={
|
|
187
194
|
<ContentEditable
|
|
195
|
+
lang={lang}
|
|
188
196
|
aria-label={ariaLabel}
|
|
189
197
|
aria-placeholder={placeholder}
|
|
190
198
|
data-testid="compose-body"
|
|
@@ -68,9 +68,9 @@ const INITIAL_STATE: ToolbarState = {
|
|
|
68
68
|
|
|
69
69
|
export interface RichTextToolbarProps {
|
|
70
70
|
/**
|
|
71
|
-
* Pinned to the right of the strip, outside the part that scrolls. The
|
|
72
|
-
*
|
|
73
|
-
* Shift+Tab out of the body reaches
|
|
71
|
+
* Pinned to the right of the strip, outside the part that scrolls. The
|
|
72
|
+
* language chip and the mode toggle ride here, in that order, so one
|
|
73
|
+
* Shift+Tab out of the body reaches the toggle and two reach the chip.
|
|
74
74
|
*/
|
|
75
75
|
trailing?: React.ReactNode;
|
|
76
76
|
}
|
|
@@ -212,7 +212,11 @@ export const RichTextToolbar = ({ trailing }: RichTextToolbarProps) => {
|
|
|
212
212
|
<Redo2 className={`size-4 ${state.canRedo ? "" : "opacity-40"}`} />
|
|
213
213
|
</ToolbarButton>
|
|
214
214
|
</div>
|
|
215
|
-
{trailing &&
|
|
215
|
+
{trailing && (
|
|
216
|
+
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
217
|
+
{trailing}
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
216
220
|
</div>
|
|
217
221
|
{linkDraft !== null && (
|
|
218
222
|
<div className="flex items-center gap-2 border-t border-line px-3 py-1.5">
|