@remit/ui 0.0.101 → 0.0.102
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-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/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 +16 -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/detect-compose-language.test.ts +51 -0
- package/src/lib/detect-compose-language.ts +42 -0
- package/src/rich-text.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.102",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src"
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"@react-types/shared": "^3.36.0",
|
|
33
33
|
"clsx": "^2",
|
|
34
34
|
"dompurify": "^3.4.7",
|
|
35
|
+
"franc-min": "^6.2.0",
|
|
35
36
|
"lexical": "0.49.0",
|
|
36
37
|
"lucide-react": "^0.468",
|
|
37
38
|
"react-aria": "^3.50.0",
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AnchorHTMLAttributes,
|
|
3
3
|
ButtonHTMLAttributes,
|
|
4
4
|
ReactNode,
|
|
5
|
+
Ref,
|
|
5
6
|
} from "react";
|
|
6
7
|
import { cn } from "../lib/cn.js";
|
|
7
8
|
|
|
@@ -13,6 +14,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
13
14
|
size?: Size;
|
|
14
15
|
/** Optional leading icon (lucide-react element). */
|
|
15
16
|
icon?: ReactNode;
|
|
17
|
+
ref?: Ref<HTMLButtonElement>;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
const base =
|
|
@@ -41,11 +43,13 @@ export function Button({
|
|
|
41
43
|
icon,
|
|
42
44
|
className,
|
|
43
45
|
children,
|
|
46
|
+
ref,
|
|
44
47
|
...props
|
|
45
48
|
}: ButtonProps) {
|
|
46
49
|
return (
|
|
47
50
|
<button
|
|
48
51
|
type="button"
|
|
52
|
+
ref={ref}
|
|
49
53
|
className={cn(base, variants[variant], sizes[size], className)}
|
|
50
54
|
{...props}
|
|
51
55
|
>
|
|
@@ -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
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -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>(
|