@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,147 @@
|
|
|
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 AddressEntry,
|
|
6
|
+
ComposeAddressField,
|
|
7
|
+
} from "./compose-address-field.js";
|
|
8
|
+
import { ComposeHeader, composeHeaderSummary } from "./compose-header.js";
|
|
9
|
+
import { ComposeSubjectField } from "./compose-subject-field.js";
|
|
10
|
+
|
|
11
|
+
const FromRow = ({ email }: { email: string }) => (
|
|
12
|
+
<div className="flex items-start gap-2">
|
|
13
|
+
{/* biome-ignore lint/a11y/noLabelWithoutControl: decorative label for a read-only value, not a form control */}
|
|
14
|
+
<label className="text-sm text-fg-muted shrink-0 w-12 pt-1.5">From:</label>
|
|
15
|
+
<div className="text-sm py-1.5">{email}</div>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Who the message is going to and what it is about. Cc and Bcc are not there
|
|
21
|
+
* until they are asked for — the common message has one recipient line, and
|
|
22
|
+
* three empty ones push the writing surface off a phone.
|
|
23
|
+
*/
|
|
24
|
+
const meta: Meta<typeof ComposeHeader> = {
|
|
25
|
+
title: "Mail/ComposeHeader",
|
|
26
|
+
component: ComposeHeader,
|
|
27
|
+
parameters: { layout: "padded" },
|
|
28
|
+
};
|
|
29
|
+
export default meta;
|
|
30
|
+
|
|
31
|
+
type Story = StoryObj<typeof ComposeHeader>;
|
|
32
|
+
|
|
33
|
+
const Harness = ({
|
|
34
|
+
initialTo = [],
|
|
35
|
+
initialCc,
|
|
36
|
+
initialBcc,
|
|
37
|
+
initialSubject = "",
|
|
38
|
+
collapsed = false,
|
|
39
|
+
}: {
|
|
40
|
+
initialTo?: AddressEntry[];
|
|
41
|
+
initialCc?: AddressEntry[];
|
|
42
|
+
initialBcc?: AddressEntry[];
|
|
43
|
+
initialSubject?: string;
|
|
44
|
+
collapsed?: boolean;
|
|
45
|
+
}) => {
|
|
46
|
+
const [to, setTo] = useState(initialTo);
|
|
47
|
+
const [cc, setCc] = useState(initialCc ?? []);
|
|
48
|
+
const [bcc, setBcc] = useState(initialBcc ?? []);
|
|
49
|
+
const [showCc, setShowCc] = useState(initialCc !== undefined);
|
|
50
|
+
const [showBcc, setShowBcc] = useState(initialBcc !== undefined);
|
|
51
|
+
const [subject, setSubject] = useState(initialSubject);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="w-[560px] border border-line bg-surface">
|
|
55
|
+
<ComposeHeader
|
|
56
|
+
collapsed={collapsed}
|
|
57
|
+
summary={composeHeaderSummary({ to, cc, bcc, subject })}
|
|
58
|
+
from={<FromRow email="alice@northwind.example" />}
|
|
59
|
+
to={
|
|
60
|
+
<ComposeAddressField
|
|
61
|
+
label="To"
|
|
62
|
+
addresses={to}
|
|
63
|
+
onChange={setTo}
|
|
64
|
+
placeholder="Recipients"
|
|
65
|
+
/>
|
|
66
|
+
}
|
|
67
|
+
cc={
|
|
68
|
+
showCc ? (
|
|
69
|
+
<ComposeAddressField label="Cc" addresses={cc} onChange={setCc} />
|
|
70
|
+
) : undefined
|
|
71
|
+
}
|
|
72
|
+
bcc={
|
|
73
|
+
showBcc ? (
|
|
74
|
+
<ComposeAddressField
|
|
75
|
+
label="Bcc"
|
|
76
|
+
addresses={bcc}
|
|
77
|
+
onChange={setBcc}
|
|
78
|
+
/>
|
|
79
|
+
) : undefined
|
|
80
|
+
}
|
|
81
|
+
subject={<ComposeSubjectField value={subject} onChange={setSubject} />}
|
|
82
|
+
onShowCc={() => setShowCc(true)}
|
|
83
|
+
onShowBcc={() => setShowBcc(true)}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const NewMessage: Story = {
|
|
90
|
+
name: "New message — nothing filled in",
|
|
91
|
+
render: () => <Harness />,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const Reply: Story = {
|
|
95
|
+
render: () => (
|
|
96
|
+
<Harness
|
|
97
|
+
initialTo={[{ email: "ada@northwind.example", displayName: "Ada" }]}
|
|
98
|
+
initialSubject="Re: Q3 planning"
|
|
99
|
+
/>
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const CcAndBccRevealed: Story = {
|
|
104
|
+
render: () => (
|
|
105
|
+
<Harness
|
|
106
|
+
initialTo={[{ email: "ada@northwind.example", displayName: "Ada" }]}
|
|
107
|
+
initialCc={[{ email: "grace@northwind.example" }]}
|
|
108
|
+
initialBcc={[]}
|
|
109
|
+
initialSubject="Re: Q3 planning"
|
|
110
|
+
/>
|
|
111
|
+
),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The software keyboard is up on a phone. The header gives its space to the
|
|
116
|
+
* writing surface and keeps one line of what is already filled in.
|
|
117
|
+
*/
|
|
118
|
+
export const Collapsed: Story = {
|
|
119
|
+
render: () => (
|
|
120
|
+
<Harness
|
|
121
|
+
collapsed
|
|
122
|
+
initialTo={[
|
|
123
|
+
{ email: "ada@northwind.example", displayName: "Ada Lovelace" },
|
|
124
|
+
]}
|
|
125
|
+
initialCc={[{ email: "grace@northwind.example" }]}
|
|
126
|
+
initialSubject="Re: Q3 planning"
|
|
127
|
+
/>
|
|
128
|
+
),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Collapsed before anything is typed: an ellipsis, not an empty bar. */
|
|
132
|
+
export const CollapsedAndEmpty: Story = {
|
|
133
|
+
render: () => <Harness collapsed />,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const RevealingCcLeavesBccOnOffer: Story = {
|
|
137
|
+
render: () => <Harness />,
|
|
138
|
+
play: async ({ canvasElement }) => {
|
|
139
|
+
const canvas = within(canvasElement);
|
|
140
|
+
await expect(canvas.queryByLabelText("Cc:")).not.toBeInTheDocument();
|
|
141
|
+
await userEvent.click(canvas.getByRole("button", { name: "Cc" }));
|
|
142
|
+
await expect(canvas.getByLabelText("Cc:")).toBeVisible();
|
|
143
|
+
await expect(canvas.queryByLabelText("Bcc:")).not.toBeInTheDocument();
|
|
144
|
+
await userEvent.click(canvas.getByRole("button", { name: "Bcc" }));
|
|
145
|
+
await expect(canvas.getByLabelText("Bcc:")).toBeVisible();
|
|
146
|
+
},
|
|
147
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { AddressEntry } from "./compose-address-field.js";
|
|
3
|
+
|
|
4
|
+
export interface ComposeHeaderProps {
|
|
5
|
+
/**
|
|
6
|
+
* The software keyboard is up on a phone and the recipient rows would eat
|
|
7
|
+
* the writing surface. The header collapses to one line of what is already
|
|
8
|
+
* filled in.
|
|
9
|
+
*/
|
|
10
|
+
collapsed?: boolean;
|
|
11
|
+
/** One line of what the collapsed header stands in for. */
|
|
12
|
+
summary?: string;
|
|
13
|
+
from: ReactNode;
|
|
14
|
+
to: ReactNode;
|
|
15
|
+
/** Present once the field has been revealed; absent leaves the reveal button. */
|
|
16
|
+
cc?: ReactNode;
|
|
17
|
+
bcc?: ReactNode;
|
|
18
|
+
subject: ReactNode;
|
|
19
|
+
onShowCc: () => void;
|
|
20
|
+
onShowBcc: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RevealButton = ({
|
|
24
|
+
label,
|
|
25
|
+
onClick,
|
|
26
|
+
}: {
|
|
27
|
+
label: string;
|
|
28
|
+
onClick: () => void;
|
|
29
|
+
}) => (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={onClick}
|
|
33
|
+
className="text-xs text-fg-muted hover:text-fg transition-colors"
|
|
34
|
+
>
|
|
35
|
+
{label}
|
|
36
|
+
</button>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export const composeHeaderSummary = ({
|
|
40
|
+
to,
|
|
41
|
+
cc,
|
|
42
|
+
bcc,
|
|
43
|
+
subject,
|
|
44
|
+
}: {
|
|
45
|
+
to: readonly AddressEntry[];
|
|
46
|
+
cc: readonly AddressEntry[];
|
|
47
|
+
bcc: readonly AddressEntry[];
|
|
48
|
+
subject: string;
|
|
49
|
+
}): string => {
|
|
50
|
+
const chips: string[] = [];
|
|
51
|
+
if (to.length > 0)
|
|
52
|
+
chips.push(`To: ${to.map((a) => a.displayName ?? a.email).join(", ")}`);
|
|
53
|
+
if (cc.length > 0) chips.push(`Cc: ${cc.length}`);
|
|
54
|
+
if (bcc.length > 0) chips.push(`Bcc: ${bcc.length}`);
|
|
55
|
+
if (subject) chips.push(subject);
|
|
56
|
+
return chips.join(" · ");
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Who the message is going to and what it is about. The fields are slots: the
|
|
61
|
+
* app fills them with the wired address and account controls, a story with
|
|
62
|
+
* static ones, and both get the same layout, the same Cc/Bcc reveal and the
|
|
63
|
+
* same collapsed line.
|
|
64
|
+
*/
|
|
65
|
+
export const ComposeHeader = ({
|
|
66
|
+
collapsed = false,
|
|
67
|
+
summary = "",
|
|
68
|
+
from,
|
|
69
|
+
to,
|
|
70
|
+
cc,
|
|
71
|
+
bcc,
|
|
72
|
+
subject,
|
|
73
|
+
onShowCc,
|
|
74
|
+
onShowBcc,
|
|
75
|
+
}: ComposeHeaderProps) => {
|
|
76
|
+
if (collapsed) {
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className="flex items-center gap-2 px-3 py-1.5 border-b border-line overflow-hidden"
|
|
80
|
+
data-testid="compose-header-collapsed"
|
|
81
|
+
>
|
|
82
|
+
<span className="truncate text-xs text-fg-muted">{summary || "…"}</span>
|
|
83
|
+
<span className="shrink-0 inline-flex items-center justify-center rounded bg-surface-sunken px-1.5 py-0.5 text-2xs text-fg-muted">
|
|
84
|
+
…
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="space-y-1 px-3 py-2 border-b border-line">
|
|
92
|
+
{from}
|
|
93
|
+
{to}
|
|
94
|
+
{cc ?? (
|
|
95
|
+
<div className="flex gap-2 pl-14">
|
|
96
|
+
<RevealButton label="Cc" onClick={onShowCc} />
|
|
97
|
+
<RevealButton label="Bcc" onClick={onShowBcc} />
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
{cc && !bcc && (
|
|
101
|
+
<div className="pl-14">
|
|
102
|
+
<RevealButton label="Bcc" onClick={onShowBcc} />
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
{bcc}
|
|
106
|
+
{subject}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
4
|
+
import { ComposeLanguageChip } from "./compose-language-chip.js";
|
|
5
|
+
|
|
6
|
+
const LANGUAGES = ["nl", "en", "de"];
|
|
7
|
+
|
|
8
|
+
const CHROME_HELP =
|
|
9
|
+
"Chrome checks every language you add under Settings, then Languages. Adding one there checks it alongside the others.";
|
|
10
|
+
const SAFARI_HELP =
|
|
11
|
+
"macOS decides this under Keyboard, then Text Input, then Spelling. Automatic by Language covers every language enabled there.";
|
|
12
|
+
const FIREFOX_HELP =
|
|
13
|
+
"Firefox uses this setting. Right-click the message to add a dictionary for it.";
|
|
14
|
+
|
|
15
|
+
const Chip = ({
|
|
16
|
+
initial = "nl",
|
|
17
|
+
helpText,
|
|
18
|
+
}: {
|
|
19
|
+
initial?: string;
|
|
20
|
+
helpText?: string;
|
|
21
|
+
}) => {
|
|
22
|
+
const [language, setLanguage] = useState(initial);
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex w-[360px] justify-end rounded-md border border-line bg-canvas p-2">
|
|
25
|
+
<ComposeLanguageChip
|
|
26
|
+
language={language}
|
|
27
|
+
languages={LANGUAGES}
|
|
28
|
+
onSelect={setLanguage}
|
|
29
|
+
helpText={helpText}
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The language control at the right of the compose toolbar. Two letters, the
|
|
37
|
+
* language in full to a screen reader, and a menu of the account's languages
|
|
38
|
+
* over one sentence naming the browser setting that actually fixes spelling.
|
|
39
|
+
*
|
|
40
|
+
* The sentence is the only part of this feature that fixes anything for a
|
|
41
|
+
* Chrome or Safari user, and it says whose setting it is.
|
|
42
|
+
*/
|
|
43
|
+
const meta: Meta<typeof Chip> = {
|
|
44
|
+
title: "Mail/ComposeLanguageChip",
|
|
45
|
+
component: Chip,
|
|
46
|
+
parameters: { layout: "centered" },
|
|
47
|
+
};
|
|
48
|
+
export default meta;
|
|
49
|
+
|
|
50
|
+
type Story = StoryObj<typeof Chip>;
|
|
51
|
+
|
|
52
|
+
export const Closed: Story = {
|
|
53
|
+
name: "The chip",
|
|
54
|
+
args: {},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const openMenu = async (canvasElement: HTMLElement): Promise<void> => {
|
|
58
|
+
await userEvent.click(
|
|
59
|
+
within(canvasElement).getByRole("button", {
|
|
60
|
+
name: /^Message language:/,
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const MenuOnChrome: Story = {
|
|
66
|
+
name: "Open, on Chrome",
|
|
67
|
+
args: { helpText: CHROME_HELP },
|
|
68
|
+
play: async ({ canvasElement }) => {
|
|
69
|
+
await openMenu(canvasElement);
|
|
70
|
+
await expect(
|
|
71
|
+
within(canvasElement).getByTestId("compose-language-help"),
|
|
72
|
+
).toHaveTextContent(CHROME_HELP);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const MenuOnSafari: Story = {
|
|
77
|
+
name: "Open, on Safari",
|
|
78
|
+
args: { helpText: SAFARI_HELP },
|
|
79
|
+
play: async ({ canvasElement }) => {
|
|
80
|
+
await openMenu(canvasElement);
|
|
81
|
+
await expect(
|
|
82
|
+
within(canvasElement).getByTestId("compose-language-help"),
|
|
83
|
+
).toHaveTextContent(SAFARI_HELP);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const MenuOnFirefox: Story = {
|
|
88
|
+
name: "Open, on Firefox",
|
|
89
|
+
args: { helpText: FIREFOX_HELP },
|
|
90
|
+
play: async ({ canvasElement }) => {
|
|
91
|
+
await openMenu(canvasElement);
|
|
92
|
+
await expect(
|
|
93
|
+
within(canvasElement).getByTestId("compose-language-help"),
|
|
94
|
+
).toHaveTextContent(FIREFOX_HELP);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Each row is a radio: the current language is the checked one, and every row
|
|
100
|
+
* carries its own `lang`, so a screen reader reads `Nederlands` in Dutch rather
|
|
101
|
+
* than sounding it out in the reader's own voice.
|
|
102
|
+
*/
|
|
103
|
+
export const MenuMarksTheCurrentLanguage: Story = {
|
|
104
|
+
name: "The current language is checked",
|
|
105
|
+
args: { initial: "de", helpText: CHROME_HELP },
|
|
106
|
+
play: async ({ canvasElement }) => {
|
|
107
|
+
await openMenu(canvasElement);
|
|
108
|
+
const canvas = within(canvasElement);
|
|
109
|
+
|
|
110
|
+
await expect(
|
|
111
|
+
canvas.getByRole("menuitemradio", { name: /Deutsch/ }),
|
|
112
|
+
).toHaveAttribute("aria-checked", "true");
|
|
113
|
+
await expect(
|
|
114
|
+
canvas.getByRole("menuitemradio", { name: /Nederlands/ }),
|
|
115
|
+
).toHaveAttribute("aria-checked", "false");
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Escape leaves without changing anything, and hands focus back to the chip. */
|
|
120
|
+
export const EscapeReturnsFocus: Story = {
|
|
121
|
+
name: "Escape closes and returns focus",
|
|
122
|
+
args: { helpText: CHROME_HELP },
|
|
123
|
+
play: async ({ canvasElement }) => {
|
|
124
|
+
await openMenu(canvasElement);
|
|
125
|
+
await userEvent.keyboard("{Escape}");
|
|
126
|
+
|
|
127
|
+
await expect(
|
|
128
|
+
within(canvasElement).queryByTestId("compose-language-menu"),
|
|
129
|
+
).toBeNull();
|
|
130
|
+
const chip = within(canvasElement).getByRole("button", {
|
|
131
|
+
name: /^Message language:/,
|
|
132
|
+
});
|
|
133
|
+
await expect(chip).toHaveFocus();
|
|
134
|
+
await expect(chip).toHaveTextContent("NL");
|
|
135
|
+
},
|
|
136
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import {
|
|
4
|
+
browserSpellcheckHelp,
|
|
5
|
+
languageChipLabel,
|
|
6
|
+
languageLabel,
|
|
7
|
+
} from "../lib/compose-language.js";
|
|
8
|
+
import { rovingNextIndex } from "../lib/roving-focus.js";
|
|
9
|
+
import { Button } from "./button.js";
|
|
10
|
+
|
|
11
|
+
export interface ComposeLanguageChipProps {
|
|
12
|
+
/** The BCP 47 tag the message is currently being written in. */
|
|
13
|
+
language: string;
|
|
14
|
+
/** The account's configured tags — the menu, and detection's candidate set. */
|
|
15
|
+
languages: readonly string[];
|
|
16
|
+
onSelect: (tag: string) => void;
|
|
17
|
+
/**
|
|
18
|
+
* The sentence naming where the browser keeps its dictionary setting.
|
|
19
|
+
* Derived from the user agent when absent.
|
|
20
|
+
*/
|
|
21
|
+
helpText?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The language control at the right of the compose toolbar: two letters, the
|
|
26
|
+
* language in full to a screen reader, and a menu of the account's languages.
|
|
27
|
+
*
|
|
28
|
+
* Nothing here claims to change spellchecking. In Chrome and Safari the
|
|
29
|
+
* underlines do not move — only the sentence at the foot of the menu names the
|
|
30
|
+
* setting that does, and it names it as the browser's, not the app's.
|
|
31
|
+
*/
|
|
32
|
+
export const ComposeLanguageChip = ({
|
|
33
|
+
language,
|
|
34
|
+
languages,
|
|
35
|
+
onSelect,
|
|
36
|
+
helpText,
|
|
37
|
+
}: ComposeLanguageChipProps) => {
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
41
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const menuId = useId();
|
|
43
|
+
|
|
44
|
+
const close = useCallback((returnFocus: boolean) => {
|
|
45
|
+
setOpen(false);
|
|
46
|
+
if (returnFocus) triggerRef.current?.focus();
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!open) return;
|
|
51
|
+
const onPointer = (event: MouseEvent) => {
|
|
52
|
+
if (containerRef.current?.contains(event.target as Node)) return;
|
|
53
|
+
setOpen(false);
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener("mousedown", onPointer);
|
|
56
|
+
return () => document.removeEventListener("mousedown", onPointer);
|
|
57
|
+
}, [open]);
|
|
58
|
+
|
|
59
|
+
// The menu takes focus as it opens, so Escape and the arrow keys have
|
|
60
|
+
// somewhere to land and the caret does not stay behind in the message.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!open) return;
|
|
63
|
+
const current = menuRef.current?.querySelector<HTMLElement>(
|
|
64
|
+
'[aria-checked="true"]',
|
|
65
|
+
);
|
|
66
|
+
const first = menuRef.current?.querySelector<HTMLElement>(
|
|
67
|
+
'[role="menuitemradio"]',
|
|
68
|
+
);
|
|
69
|
+
(current ?? first)?.focus();
|
|
70
|
+
}, [open]);
|
|
71
|
+
|
|
72
|
+
const onMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
73
|
+
if (event.key === "Escape") {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
close(true);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const items = [
|
|
79
|
+
...(menuRef.current?.querySelectorAll<HTMLElement>(
|
|
80
|
+
'[role="menuitemradio"]',
|
|
81
|
+
) ?? []),
|
|
82
|
+
];
|
|
83
|
+
const index = items.indexOf(document.activeElement as HTMLElement);
|
|
84
|
+
const next = rovingNextIndex(event.key, index, items.length);
|
|
85
|
+
if (next === null) return;
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
items[next]?.focus();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const pick = (tag: string) => {
|
|
91
|
+
onSelect(tag);
|
|
92
|
+
close(true);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div ref={containerRef} className="relative">
|
|
97
|
+
<Button
|
|
98
|
+
ref={triggerRef}
|
|
99
|
+
variant="ghost"
|
|
100
|
+
size="md"
|
|
101
|
+
aria-label={`Message language: ${languageLabel(language)}`}
|
|
102
|
+
aria-haspopup="menu"
|
|
103
|
+
aria-expanded={open}
|
|
104
|
+
aria-controls={open ? menuId : undefined}
|
|
105
|
+
onClick={() => setOpen((value) => !value)}
|
|
106
|
+
data-testid="compose-language-chip"
|
|
107
|
+
data-language={language}
|
|
108
|
+
className="min-h-11 min-w-11 shrink-0 px-2 font-medium"
|
|
109
|
+
>
|
|
110
|
+
{languageChipLabel(language)}
|
|
111
|
+
</Button>
|
|
112
|
+
{open && (
|
|
113
|
+
<div
|
|
114
|
+
ref={menuRef}
|
|
115
|
+
id={menuId}
|
|
116
|
+
role="menu"
|
|
117
|
+
aria-label="Message language"
|
|
118
|
+
onKeyDown={onMenuKeyDown}
|
|
119
|
+
data-testid="compose-language-menu"
|
|
120
|
+
className="absolute right-0 top-full z-50 mt-1 flex max-h-[60dvh] min-w-56 flex-col overflow-y-auto overscroll-contain rounded-md border border-line bg-surface py-1 shadow-lg"
|
|
121
|
+
>
|
|
122
|
+
{languages.map((tag) => (
|
|
123
|
+
<button
|
|
124
|
+
key={tag}
|
|
125
|
+
type="button"
|
|
126
|
+
role="menuitemradio"
|
|
127
|
+
aria-checked={tag === language}
|
|
128
|
+
lang={tag}
|
|
129
|
+
onClick={() => pick(tag)}
|
|
130
|
+
className={cn(
|
|
131
|
+
"flex min-h-11 items-center justify-between gap-3 px-4 py-2.5 text-left text-sm text-fg transition-colors hover:bg-surface-sunken",
|
|
132
|
+
tag === language && "bg-accent-2-soft",
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
{languageLabel(tag)}
|
|
136
|
+
<span className="shrink-0 text-xs text-fg-subtle">
|
|
137
|
+
{languageChipLabel(tag)}
|
|
138
|
+
</span>
|
|
139
|
+
</button>
|
|
140
|
+
))}
|
|
141
|
+
<p
|
|
142
|
+
data-testid="compose-language-help"
|
|
143
|
+
className="border-t border-line px-4 pb-1 pt-2 text-xs text-fg-muted"
|
|
144
|
+
>
|
|
145
|
+
{helpText ?? browserSpellcheckHelp(navigator.userAgent)}
|
|
146
|
+
</p>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
4
|
+
import { ComposeLanguageSetting } from "./compose-language-setting.js";
|
|
5
|
+
|
|
6
|
+
const Setting = ({ initial = ["nl", "en"] }: { initial?: string[] }) => {
|
|
7
|
+
const [languages, setLanguages] = useState(initial);
|
|
8
|
+
return (
|
|
9
|
+
<div className="w-[420px] rounded-md border border-line bg-canvas p-4">
|
|
10
|
+
<ComposeLanguageSetting value={languages} onChange={setLanguages} />
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The account's writing languages, in settings. One list doing two jobs: the
|
|
17
|
+
* menu the composer's chip offers, and the set detection chooses inside — which
|
|
18
|
+
* is what keeps detection accurate on a single sentence.
|
|
19
|
+
*/
|
|
20
|
+
const meta: Meta<typeof Setting> = {
|
|
21
|
+
title: "Settings/ComposeLanguages",
|
|
22
|
+
component: Setting,
|
|
23
|
+
parameters: { layout: "centered" },
|
|
24
|
+
};
|
|
25
|
+
export default meta;
|
|
26
|
+
|
|
27
|
+
type Story = StoryObj<typeof Setting>;
|
|
28
|
+
|
|
29
|
+
export const TwoLanguages: Story = {
|
|
30
|
+
name: "Two configured languages",
|
|
31
|
+
args: {},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The last language cannot be removed: an empty list is a chip with no menu. */
|
|
35
|
+
export const OneLanguageCannotBeEmptied: Story = {
|
|
36
|
+
name: "The last language stays",
|
|
37
|
+
args: { initial: ["nl"] },
|
|
38
|
+
play: async ({ canvasElement }) => {
|
|
39
|
+
await expect(
|
|
40
|
+
within(canvasElement).getByRole("button", { name: "Remove Nederlands" }),
|
|
41
|
+
).toBeDisabled();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const AddingALanguage: Story = {
|
|
46
|
+
name: "Adding one from the list",
|
|
47
|
+
args: { initial: ["nl"] },
|
|
48
|
+
play: async ({ canvasElement }) => {
|
|
49
|
+
const canvas = within(canvasElement);
|
|
50
|
+
await userEvent.selectOptions(
|
|
51
|
+
canvas.getByRole("combobox", { name: "Add a language" }),
|
|
52
|
+
"de",
|
|
53
|
+
);
|
|
54
|
+
await expect(canvas.getByTestId("compose-language-row-de")).toBeVisible();
|
|
55
|
+
await expect(
|
|
56
|
+
canvas.getByRole("button", { name: "Remove Nederlands" }),
|
|
57
|
+
).toBeEnabled();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The first entry is what a new message opens on, and it can be moved. */
|
|
62
|
+
export const ChangingTheDefault: Story = {
|
|
63
|
+
name: "Promoting a language to the default",
|
|
64
|
+
args: { initial: ["nl", "en", "de"] },
|
|
65
|
+
play: async ({ canvasElement }) => {
|
|
66
|
+
const canvas = within(canvasElement);
|
|
67
|
+
await userEvent.click(
|
|
68
|
+
canvas.getByRole("button", {
|
|
69
|
+
name: "Write new messages in English by default",
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const rows = canvasElement.querySelectorAll(
|
|
74
|
+
"[data-testid^=compose-language-row-]",
|
|
75
|
+
);
|
|
76
|
+
await expect(rows[0]).toHaveAttribute(
|
|
77
|
+
"data-testid",
|
|
78
|
+
"compose-language-row-en",
|
|
79
|
+
);
|
|
80
|
+
await expect(rows[0]).toHaveTextContent("Default");
|
|
81
|
+
},
|
|
82
|
+
};
|