@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.103",
|
|
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,138 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
4
|
+
import {
|
|
5
|
+
type AddressEntry,
|
|
6
|
+
ComposeAddressField,
|
|
7
|
+
} from "./compose-address-field.js";
|
|
8
|
+
|
|
9
|
+
const KNOWN: AddressEntry[] = [
|
|
10
|
+
{ email: "ada@northwind.example", displayName: "Ada Lovelace" },
|
|
11
|
+
{ email: "grace@northwind.example", displayName: "Grace Hopper" },
|
|
12
|
+
{ email: "ops@northwind.example" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Recipients as chips, with a typeahead over the addresses the account already
|
|
17
|
+
* knows. Nothing here fetches: the app hands the candidates in and is told what
|
|
18
|
+
* has been typed, which is what makes the empty-result story below the same
|
|
19
|
+
* component the app renders.
|
|
20
|
+
*/
|
|
21
|
+
const meta: Meta<typeof ComposeAddressField> = {
|
|
22
|
+
title: "Mail/ComposeAddressField",
|
|
23
|
+
component: ComposeAddressField,
|
|
24
|
+
parameters: { layout: "padded" },
|
|
25
|
+
};
|
|
26
|
+
export default meta;
|
|
27
|
+
|
|
28
|
+
type Story = StoryObj<typeof ComposeAddressField>;
|
|
29
|
+
|
|
30
|
+
const Harness = ({
|
|
31
|
+
initial = [],
|
|
32
|
+
candidates = KNOWN,
|
|
33
|
+
label = "To",
|
|
34
|
+
}: {
|
|
35
|
+
initial?: AddressEntry[];
|
|
36
|
+
candidates?: AddressEntry[];
|
|
37
|
+
label?: string;
|
|
38
|
+
}) => {
|
|
39
|
+
const [addresses, setAddresses] = useState<AddressEntry[]>(initial);
|
|
40
|
+
const [query, setQuery] = useState("");
|
|
41
|
+
const suggestions = candidates.filter((candidate) =>
|
|
42
|
+
`${candidate.displayName ?? ""} ${candidate.email}`
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.includes(query.toLowerCase()),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="w-[520px]">
|
|
49
|
+
<ComposeAddressField
|
|
50
|
+
label={label}
|
|
51
|
+
addresses={addresses}
|
|
52
|
+
onChange={setAddresses}
|
|
53
|
+
placeholder="Recipients"
|
|
54
|
+
suggestions={query.length >= 2 ? suggestions : []}
|
|
55
|
+
onQueryChange={setQuery}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const Empty: Story = {
|
|
62
|
+
name: "Empty — the placeholder is the only content",
|
|
63
|
+
render: () => <Harness />,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const WithRecipients: Story = {
|
|
67
|
+
render: () => (
|
|
68
|
+
<Harness
|
|
69
|
+
initial={[
|
|
70
|
+
{ email: "ada@northwind.example", displayName: "Ada Lovelace" },
|
|
71
|
+
{ email: "ops@northwind.example" },
|
|
72
|
+
]}
|
|
73
|
+
/>
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const SuggestionsOffered: Story = {
|
|
78
|
+
render: () => <Harness />,
|
|
79
|
+
play: async ({ canvasElement }) => {
|
|
80
|
+
const input = within(canvasElement).getByLabelText("To:");
|
|
81
|
+
await userEvent.type(input, "ada");
|
|
82
|
+
const list = await within(canvasElement).findByRole("listbox");
|
|
83
|
+
await expect(within(list).getByText("Ada Lovelace")).toBeVisible();
|
|
84
|
+
await userEvent.click(within(list).getByText("Ada Lovelace"));
|
|
85
|
+
await expect(
|
|
86
|
+
within(canvasElement).getByText("Ada Lovelace"),
|
|
87
|
+
).toBeInTheDocument();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Nothing matches. The field stays a plain text field — an address the account
|
|
93
|
+
* has never written to is still a valid address, and typing it out is the
|
|
94
|
+
* normal case, not a failure.
|
|
95
|
+
*/
|
|
96
|
+
export const NoMatches: Story = {
|
|
97
|
+
render: () => <Harness candidates={[]} />,
|
|
98
|
+
play: async ({ canvasElement }) => {
|
|
99
|
+
const canvas = within(canvasElement);
|
|
100
|
+
const input = canvas.getByLabelText("To:");
|
|
101
|
+
await userEvent.type(input, "someone@elsewhere.example{enter}");
|
|
102
|
+
await expect(canvas.queryByRole("listbox")).not.toBeInTheDocument();
|
|
103
|
+
await expect(canvas.getByText("someone@elsewhere.example")).toBeVisible();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** What is not an address stays in the field rather than becoming a chip. */
|
|
108
|
+
export const IncompleteAddressIsNotTaken: Story = {
|
|
109
|
+
render: () => <Harness candidates={[]} />,
|
|
110
|
+
play: async ({ canvasElement }) => {
|
|
111
|
+
const input = within(canvasElement).getByLabelText<HTMLInputElement>("To:");
|
|
112
|
+
await userEvent.type(input, "not-an-address{enter}");
|
|
113
|
+
await expect(input).toHaveValue("not-an-address");
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const BackspaceRemovesTheLastChip: Story = {
|
|
118
|
+
render: () => (
|
|
119
|
+
<Harness
|
|
120
|
+
initial={[
|
|
121
|
+
{ email: "ada@northwind.example", displayName: "Ada Lovelace" },
|
|
122
|
+
{ email: "ops@northwind.example" },
|
|
123
|
+
]}
|
|
124
|
+
/>
|
|
125
|
+
),
|
|
126
|
+
play: async ({ canvasElement }) => {
|
|
127
|
+
const canvas = within(canvasElement);
|
|
128
|
+
const input = canvas.getByLabelText("To:");
|
|
129
|
+
await userEvent.click(input);
|
|
130
|
+
await userEvent.keyboard("{Backspace}");
|
|
131
|
+
await waitFor(() =>
|
|
132
|
+
expect(
|
|
133
|
+
canvas.queryByText("ops@northwind.example"),
|
|
134
|
+
).not.toBeInTheDocument(),
|
|
135
|
+
);
|
|
136
|
+
await expect(canvas.getByText("Ada Lovelace")).toBeVisible();
|
|
137
|
+
},
|
|
138
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useSuggestList } from "../lib/use-suggest-list.js";
|
|
3
|
+
import { AddressTag } from "./address-tag.js";
|
|
4
|
+
import { type Suggestion, SuggestList } from "./suggest-list.js";
|
|
5
|
+
|
|
6
|
+
export interface AddressEntry {
|
|
7
|
+
email: string;
|
|
8
|
+
displayName?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ComposeAddressFieldProps {
|
|
12
|
+
label: string;
|
|
13
|
+
addresses: AddressEntry[];
|
|
14
|
+
onChange: (addresses: AddressEntry[]) => void;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
/** Candidates for what is typed so far. The caller looks them up. */
|
|
17
|
+
suggestions?: readonly AddressEntry[];
|
|
18
|
+
/** The current text, reported so the caller can look candidates up. */
|
|
19
|
+
onQueryChange?: (query: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Beyond Enter, the keys that take the highlighted suggestion in a chips field. */
|
|
23
|
+
const ACCEPT_KEYS = ["Tab", ","] as const;
|
|
24
|
+
|
|
25
|
+
const isValidEmail = (value: string): boolean =>
|
|
26
|
+
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
27
|
+
|
|
28
|
+
const parseEmailInput = (value: string): AddressEntry | undefined => {
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
if (!trimmed) return undefined;
|
|
31
|
+
|
|
32
|
+
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
|
|
33
|
+
if (angleMatch) {
|
|
34
|
+
const displayName = angleMatch[1].trim();
|
|
35
|
+
const email = angleMatch[2].trim();
|
|
36
|
+
if (isValidEmail(email)) return { email, displayName };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (isValidEmail(trimmed)) return { email: trimmed };
|
|
40
|
+
return undefined;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const ComposeAddressField = ({
|
|
44
|
+
label,
|
|
45
|
+
addresses,
|
|
46
|
+
onChange,
|
|
47
|
+
placeholder,
|
|
48
|
+
suggestions = [],
|
|
49
|
+
onQueryChange,
|
|
50
|
+
}: ComposeAddressFieldProps) => {
|
|
51
|
+
const [inputValue, setInputValue] = useState("");
|
|
52
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
53
|
+
|
|
54
|
+
const existingEmails = new Set(addresses.map((a) => a.email.toLowerCase()));
|
|
55
|
+
const filteredSuggestions =
|
|
56
|
+
inputValue.length >= 2
|
|
57
|
+
? suggestions.filter((s) => !existingEmails.has(s.email.toLowerCase()))
|
|
58
|
+
: [];
|
|
59
|
+
|
|
60
|
+
const addAddress = useCallback(
|
|
61
|
+
(entry: AddressEntry) => {
|
|
62
|
+
if (existingEmails.has(entry.email.toLowerCase())) return;
|
|
63
|
+
onChange([...addresses, entry]);
|
|
64
|
+
setInputValue("");
|
|
65
|
+
onQueryChange?.("");
|
|
66
|
+
},
|
|
67
|
+
[addresses, existingEmails, onChange, onQueryChange],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const removeAddress = useCallback(
|
|
71
|
+
(index: number) => {
|
|
72
|
+
onChange(addresses.filter((_, i) => i !== index));
|
|
73
|
+
},
|
|
74
|
+
[addresses, onChange],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const selectSuggestion = useCallback(
|
|
78
|
+
(suggestion: AddressEntry) => {
|
|
79
|
+
addAddress(suggestion);
|
|
80
|
+
inputRef.current?.focus();
|
|
81
|
+
},
|
|
82
|
+
[addAddress],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const commitInput = useCallback(() => {
|
|
86
|
+
const entry = parseEmailInput(inputValue);
|
|
87
|
+
if (entry) {
|
|
88
|
+
addAddress(entry);
|
|
89
|
+
}
|
|
90
|
+
}, [inputValue, addAddress]);
|
|
91
|
+
|
|
92
|
+
// The open state, the highlight, and the arrow/Enter/Escape handling are the
|
|
93
|
+
// app's one typeahead behaviour, shared with the filter-rule value field.
|
|
94
|
+
const suggest = useSuggestList({
|
|
95
|
+
count: filteredSuggestions.length,
|
|
96
|
+
acceptKeys: ACCEPT_KEYS,
|
|
97
|
+
onAccept: (index) => selectSuggestion(filteredSuggestions[index]),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const options = useMemo<Suggestion[]>(
|
|
101
|
+
() =>
|
|
102
|
+
filteredSuggestions.map((suggestion) => ({
|
|
103
|
+
value: suggestion.email,
|
|
104
|
+
label: suggestion.displayName ?? suggestion.email,
|
|
105
|
+
...(suggestion.displayName ? { hint: suggestion.email } : {}),
|
|
106
|
+
})),
|
|
107
|
+
[filteredSuggestions],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const handleKeyDown = useCallback(
|
|
111
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
112
|
+
if (e.key === "Backspace" && inputValue === "" && addresses.length > 0) {
|
|
113
|
+
removeAddress(addresses.length - 1);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (suggest.handleKeyDown(e)) return;
|
|
118
|
+
|
|
119
|
+
if (e.key === "Enter" || e.key === "Tab" || e.key === ",") {
|
|
120
|
+
if (inputValue.trim()) {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
commitInput();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[
|
|
127
|
+
inputValue,
|
|
128
|
+
addresses.length,
|
|
129
|
+
removeAddress,
|
|
130
|
+
suggest.handleKeyDown,
|
|
131
|
+
commitInput,
|
|
132
|
+
],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const handleBlur = useCallback(() => {
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
commitInput();
|
|
138
|
+
suggest.dismiss();
|
|
139
|
+
}, 150);
|
|
140
|
+
}, [commitInput, suggest.dismiss]);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="relative">
|
|
144
|
+
<div className="flex items-start gap-2">
|
|
145
|
+
<label
|
|
146
|
+
htmlFor={`address-field-${label}`}
|
|
147
|
+
className="text-sm text-fg-muted shrink-0 w-12 pt-1.5"
|
|
148
|
+
>
|
|
149
|
+
{label}:
|
|
150
|
+
</label>
|
|
151
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: click-to-focus wrapper for the address input; keyboard is forwarded to the inner input */}
|
|
152
|
+
<div
|
|
153
|
+
className="flex-1 flex flex-wrap items-center gap-1 min-h-[36px] px-2 py-1 border rounded-md bg-canvas cursor-text"
|
|
154
|
+
onClick={() => inputRef.current?.focus()}
|
|
155
|
+
onKeyDown={(e) => {
|
|
156
|
+
if (e.key === "Enter" || e.key === " ") inputRef.current?.focus();
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{addresses.map((addr, i) => (
|
|
160
|
+
<AddressTag
|
|
161
|
+
key={addr.email}
|
|
162
|
+
email={addr.email}
|
|
163
|
+
displayName={addr.displayName}
|
|
164
|
+
onRemove={() => removeAddress(i)}
|
|
165
|
+
/>
|
|
166
|
+
))}
|
|
167
|
+
<input
|
|
168
|
+
ref={inputRef}
|
|
169
|
+
id={`address-field-${label}`}
|
|
170
|
+
type="text"
|
|
171
|
+
value={inputValue}
|
|
172
|
+
onChange={(e) => {
|
|
173
|
+
suggest.reopen();
|
|
174
|
+
setInputValue(e.target.value);
|
|
175
|
+
onQueryChange?.(e.target.value);
|
|
176
|
+
}}
|
|
177
|
+
onKeyDown={handleKeyDown}
|
|
178
|
+
onBlur={handleBlur}
|
|
179
|
+
placeholder={addresses.length === 0 ? placeholder : ""}
|
|
180
|
+
className="flex-1 min-w-[120px] bg-transparent outline-none text-sm py-0.5"
|
|
181
|
+
autoComplete="off"
|
|
182
|
+
{...suggest.comboboxProps}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{suggest.open && (
|
|
188
|
+
<SuggestList
|
|
189
|
+
id={suggest.listId}
|
|
190
|
+
suggestions={options}
|
|
191
|
+
activeIndex={suggest.activeIndex}
|
|
192
|
+
optionId={suggest.optionId}
|
|
193
|
+
onPick={(option) => {
|
|
194
|
+
const picked = filteredSuggestions.find(
|
|
195
|
+
(suggestion) => suggestion.email === option.value,
|
|
196
|
+
);
|
|
197
|
+
if (picked) selectSuggestion(picked);
|
|
198
|
+
}}
|
|
199
|
+
onHighlight={suggest.setActiveIndex}
|
|
200
|
+
label={`${label} suggestions`}
|
|
201
|
+
className="absolute left-12 right-0 z-50 mt-1 max-h-[200px] shadow-lg"
|
|
202
|
+
/>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
};
|