@remit/ui 0.0.102 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.102",
3
+ "version": "0.0.103",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
+ };