@remit/web-client 0.0.138 → 0.0.139

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/web-client",
3
- "version": "0.0.138",
3
+ "version": "0.0.139",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -29,7 +29,7 @@
29
29
  "build:dist": "npm run generate:routes && node --import tsx harness/build.mjs",
30
30
  "preview": "vite preview",
31
31
  "test:typecheck": "npm run generate:routes && tsgo --noEmit && tsgo --noEmit -p tsconfig.node.json",
32
- "test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
32
+ "test:run": "TSX_TSCONFIG_PATH=./tsconfig.test.json node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
33
33
  "test": "npm run test:typecheck && npm run test:run"
34
34
  },
35
35
  "peerDependencies": {
@@ -1,19 +1,10 @@
1
1
  import { addressOperationsSearchAddressesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
3
- import {
4
- AddressTag,
5
- type Suggestion,
6
- SuggestList,
7
- useSuggestList,
8
- } from "@remit/ui";
2
+ import { type AddressEntry, ComposeAddressField } from "@remit/ui";
9
3
  import { useQuery } from "@tanstack/react-query";
10
- import { useCallback, useMemo, useRef, useState } from "react";
4
+ import { useMemo, useState } from "react";
11
5
  import { useDebouncedValue } from "@/hooks/useDebouncedValue";
12
6
 
13
- export interface AddressEntry {
14
- email: string;
15
- displayName?: string;
16
- }
7
+ export type { AddressEntry };
17
8
 
18
9
  interface AddressFieldProps {
19
10
  label: string;
@@ -22,199 +13,40 @@ interface AddressFieldProps {
22
13
  placeholder?: string;
23
14
  }
24
15
 
25
- /** Beyond Enter, the keys that take the highlighted suggestion in a chips field. */
26
- const ACCEPT_KEYS = ["Tab", ","] as const;
27
-
28
- const isValidEmail = (value: string): boolean =>
29
- /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
30
-
31
- const parseEmailInput = (value: string): AddressEntry | undefined => {
32
- const trimmed = value.trim();
33
- if (!trimmed) return undefined;
34
-
35
- const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
36
- if (angleMatch) {
37
- const displayName = angleMatch[1].trim();
38
- const email = angleMatch[2].trim();
39
- if (isValidEmail(email)) return { email, displayName };
40
- }
41
-
42
- if (isValidEmail(trimmed)) return { email: trimmed };
43
- return undefined;
44
- };
45
-
16
+ /** Looks the account's known correspondents up for the field to offer. */
46
17
  export const AddressField = ({
47
18
  label,
48
19
  addresses,
49
20
  onChange,
50
21
  placeholder,
51
22
  }: AddressFieldProps) => {
52
- const [inputValue, setInputValue] = useState("");
53
- const inputRef = useRef<HTMLInputElement>(null);
54
-
55
- const debouncedQuery = useDebouncedValue(inputValue, 200);
23
+ const [query, setQuery] = useState("");
24
+ const debouncedQuery = useDebouncedValue(query, 200);
56
25
 
57
- const { data: suggestionsData } = useQuery({
26
+ const { data } = useQuery({
58
27
  ...addressOperationsSearchAddressesOptions({
59
28
  query: { q: debouncedQuery, limit: 8 },
60
29
  }),
61
30
  enabled: debouncedQuery.length >= 2,
62
31
  });
63
32
 
64
- const suggestions = suggestionsData?.items ?? [];
65
- const existingEmails = new Set(addresses.map((a) => a.email.toLowerCase()));
66
- const filteredSuggestions =
67
- inputValue.length >= 2
68
- ? suggestions.filter(
69
- (s) => !existingEmails.has(s.normalizedEmail.toLowerCase()),
70
- )
71
- : [];
72
-
73
- const addAddress = useCallback(
74
- (entry: AddressEntry) => {
75
- if (existingEmails.has(entry.email.toLowerCase())) return;
76
- onChange([...addresses, entry]);
77
- setInputValue("");
78
- },
79
- [addresses, existingEmails, onChange],
80
- );
81
-
82
- const removeAddress = useCallback(
83
- (index: number) => {
84
- onChange(addresses.filter((_, i) => i !== index));
85
- },
86
- [addresses, onChange],
87
- );
88
-
89
- const selectSuggestion = useCallback(
90
- (suggestion: RemitImapAddressResponse) => {
91
- addAddress({
92
- email: suggestion.normalizedEmail,
93
- displayName: suggestion.displayName,
94
- });
95
- inputRef.current?.focus();
96
- },
97
- [addAddress],
98
- );
99
-
100
- const commitInput = useCallback(() => {
101
- const entry = parseEmailInput(inputValue);
102
- if (entry) {
103
- addAddress(entry);
104
- }
105
- }, [inputValue, addAddress]);
106
-
107
- // The open state, the highlight, and the arrow/Enter/Escape handling are the
108
- // app's one typeahead behaviour, shared with the filter-rule value field.
109
- const suggest = useSuggestList({
110
- count: filteredSuggestions.length,
111
- acceptKeys: ACCEPT_KEYS,
112
- onAccept: (index) => selectSuggestion(filteredSuggestions[index]),
113
- });
114
-
115
- const options = useMemo<Suggestion[]>(
33
+ const suggestions = useMemo<AddressEntry[]>(
116
34
  () =>
117
- filteredSuggestions.map((suggestion) => ({
118
- value: suggestion.normalizedEmail,
119
- label: suggestion.displayName ?? suggestion.normalizedEmail,
120
- ...(suggestion.displayName ? { hint: suggestion.normalizedEmail } : {}),
35
+ (data?.items ?? []).map((item) => ({
36
+ email: item.normalizedEmail,
37
+ displayName: item.displayName,
121
38
  })),
122
- [filteredSuggestions],
39
+ [data],
123
40
  );
124
41
 
125
- const handleKeyDown = useCallback(
126
- (e: React.KeyboardEvent<HTMLInputElement>) => {
127
- if (e.key === "Backspace" && inputValue === "" && addresses.length > 0) {
128
- removeAddress(addresses.length - 1);
129
- return;
130
- }
131
-
132
- if (suggest.handleKeyDown(e)) return;
133
-
134
- if (e.key === "Enter" || e.key === "Tab" || e.key === ",") {
135
- if (inputValue.trim()) {
136
- e.preventDefault();
137
- commitInput();
138
- }
139
- }
140
- },
141
- [
142
- inputValue,
143
- addresses.length,
144
- removeAddress,
145
- suggest.handleKeyDown,
146
- commitInput,
147
- ],
148
- );
149
-
150
- const handleBlur = useCallback(() => {
151
- setTimeout(() => {
152
- commitInput();
153
- suggest.dismiss();
154
- }, 150);
155
- }, [commitInput, suggest.dismiss]);
156
-
157
42
  return (
158
- <div className="relative">
159
- <div className="flex items-start gap-2">
160
- <label
161
- htmlFor={`address-field-${label}`}
162
- className="text-sm text-fg-muted shrink-0 w-12 pt-1.5"
163
- >
164
- {label}:
165
- </label>
166
- {/* biome-ignore lint/a11y/noStaticElementInteractions: click-to-focus wrapper for the address input; keyboard is forwarded to the inner input */}
167
- <div
168
- className="flex-1 flex flex-wrap items-center gap-1 min-h-[36px] px-2 py-1 border rounded-md bg-canvas cursor-text"
169
- onClick={() => inputRef.current?.focus()}
170
- onKeyDown={(e) => {
171
- if (e.key === "Enter" || e.key === " ") inputRef.current?.focus();
172
- }}
173
- >
174
- {addresses.map((addr, i) => (
175
- <AddressTag
176
- key={addr.email}
177
- email={addr.email}
178
- displayName={addr.displayName}
179
- onRemove={() => removeAddress(i)}
180
- />
181
- ))}
182
- <input
183
- ref={inputRef}
184
- id={`address-field-${label}`}
185
- type="text"
186
- value={inputValue}
187
- onChange={(e) => {
188
- suggest.reopen();
189
- setInputValue(e.target.value);
190
- }}
191
- onKeyDown={handleKeyDown}
192
- onBlur={handleBlur}
193
- placeholder={addresses.length === 0 ? placeholder : ""}
194
- className="flex-1 min-w-[120px] bg-transparent outline-none text-sm py-0.5"
195
- autoComplete="off"
196
- {...suggest.comboboxProps}
197
- />
198
- </div>
199
- </div>
200
-
201
- {suggest.open && (
202
- <SuggestList
203
- id={suggest.listId}
204
- suggestions={options}
205
- activeIndex={suggest.activeIndex}
206
- optionId={suggest.optionId}
207
- onPick={(option) => {
208
- const picked = filteredSuggestions.find(
209
- (suggestion) => suggestion.normalizedEmail === option.value,
210
- );
211
- if (picked) selectSuggestion(picked);
212
- }}
213
- onHighlight={suggest.setActiveIndex}
214
- label={`${label} suggestions`}
215
- className="absolute left-12 right-0 z-50 mt-1 max-h-[200px] shadow-lg"
216
- />
217
- )}
218
- </div>
43
+ <ComposeAddressField
44
+ label={label}
45
+ addresses={addresses}
46
+ onChange={onChange}
47
+ placeholder={placeholder}
48
+ suggestions={suggestions}
49
+ onQueryChange={setQuery}
50
+ />
219
51
  );
220
52
  };
@@ -11,8 +11,12 @@ import type {
11
11
  import {
12
12
  ComposeActionBar,
13
13
  ComposeFormShell,
14
+ ComposeHeader,
15
+ ComposeSubjectField,
16
+ composeHeaderSummary,
14
17
  defaultComposeLanguages,
15
18
  EMPTY_RICH_TEXT,
19
+ modeOfDraft,
16
20
  QuotedText,
17
21
  type RichTextValue,
18
22
  sanitizeQuotedHtml,
@@ -42,10 +46,9 @@ import {
42
46
  import type { AddressEntry } from "./AddressField";
43
47
  import { AddressField } from "./AddressField";
44
48
  import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
45
- import { modeOfDraft } from "./compose-mode";
46
49
 
47
50
  const LazyComposeBody = lazy(() =>
48
- import("./ComposeBody.js").then((m) => ({ default: m.ComposeBody })),
51
+ import("@remit/ui/rich-text").then((m) => ({ default: m.ComposeBody })),
49
52
  );
50
53
 
51
54
  const ComposeBodyFallback = () => (
@@ -60,7 +63,6 @@ import { useVisualViewport } from "../../hooks/useVisualViewport.js";
60
63
  import type { ComposeMode } from "./ComposeProvider";
61
64
  import { useCompose } from "./ComposeProvider";
62
65
  import { FromSelector } from "./FromSelector";
63
- import { SubjectField } from "./SubjectField";
64
66
 
65
67
  interface ComposeFormProps {
66
68
  mode: ComposeMode;
@@ -195,10 +197,10 @@ const isFormEmpty = (
195
197
  body.text.trim() === "";
196
198
 
197
199
  // ---------------------------------------------------------------------------
198
- // ComposeHeadercollapsed on mobile when the software keyboard is open
200
+ // WiredComposeHeaderthe shared header, with the app's fields in its slots
199
201
  // ---------------------------------------------------------------------------
200
202
 
201
- interface ComposeHeaderProps {
203
+ interface WiredComposeHeaderProps {
202
204
  selectedAccountId?: string;
203
205
  onAccountChange: (account: RemitImapAccountResponse) => void;
204
206
  toAddresses: AddressEntry[];
@@ -215,7 +217,7 @@ interface ComposeHeaderProps {
215
217
  setSubject: (v: string) => void;
216
218
  }
217
219
 
218
- const ComposeHeader = ({
220
+ const WiredComposeHeader = ({
219
221
  selectedAccountId,
220
222
  onAccountChange,
221
223
  toAddresses,
@@ -230,93 +232,55 @@ const ComposeHeader = ({
230
232
  setShowBcc,
231
233
  subject,
232
234
  setSubject,
233
- }: ComposeHeaderProps) => {
235
+ }: WiredComposeHeaderProps) => {
234
236
  const isDesktop = useIsDesktop();
235
237
  const { isKeyboardOpen } = useVisualViewport();
236
- const collapsed = !isDesktop && isKeyboardOpen;
237
-
238
- if (collapsed) {
239
- // Compact single-line summary when the keyboard eats vertical space
240
- const chips: string[] = [];
241
- if (toAddresses.length > 0)
242
- chips.push(
243
- `To: ${toAddresses.map((a) => a.displayName ?? a.email).join(", ")}`,
244
- );
245
- if (ccAddresses.length > 0) chips.push(`Cc: ${ccAddresses.length}`);
246
- if (bccAddresses.length > 0) chips.push(`Bcc: ${bccAddresses.length}`);
247
- if (subject) chips.push(subject);
248
-
249
- return (
250
- <div
251
- className="flex items-center gap-2 px-3 py-1.5 border-b border-line overflow-hidden"
252
- data-testid="compose-header-collapsed"
253
- >
254
- <span className="truncate text-xs text-fg-muted">
255
- {chips.length > 0 ? chips.join(" · ") : "…"}
256
- </span>
257
- <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">
258
-
259
- </span>
260
- </div>
261
- );
262
- }
263
238
 
264
239
  return (
265
- <div className="space-y-1 px-3 py-2 border-b border-line">
266
- <FromSelector
267
- selectedAccountId={selectedAccountId}
268
- onSelect={onAccountChange}
269
- />
270
- <AddressField
271
- label="To"
272
- addresses={toAddresses}
273
- onChange={setToAddresses}
274
- placeholder="Recipients"
275
- />
276
- {showCc ? (
277
- <AddressField
278
- label="Cc"
279
- addresses={ccAddresses}
280
- onChange={setCcAddresses}
240
+ <ComposeHeader
241
+ collapsed={!isDesktop && isKeyboardOpen}
242
+ summary={composeHeaderSummary({
243
+ to: toAddresses,
244
+ cc: ccAddresses,
245
+ bcc: bccAddresses,
246
+ subject,
247
+ })}
248
+ from={
249
+ <FromSelector
250
+ selectedAccountId={selectedAccountId}
251
+ onSelect={onAccountChange}
281
252
  />
282
- ) : (
283
- <div className="flex gap-2 pl-14">
284
- <button
285
- type="button"
286
- onClick={() => setShowCc(true)}
287
- className="text-xs text-fg-muted hover:text-fg transition-colors"
288
- >
289
- Cc
290
- </button>
291
- <button
292
- type="button"
293
- onClick={() => setShowBcc(true)}
294
- className="text-xs text-fg-muted hover:text-fg transition-colors"
295
- >
296
- Bcc
297
- </button>
298
- </div>
299
- )}
300
- {showCc && !showBcc && (
301
- <div className="pl-14">
302
- <button
303
- type="button"
304
- onClick={() => setShowBcc(true)}
305
- className="text-xs text-fg-muted hover:text-fg transition-colors"
306
- >
307
- Bcc
308
- </button>
309
- </div>
310
- )}
311
- {showBcc && (
253
+ }
254
+ to={
312
255
  <AddressField
313
- label="Bcc"
314
- addresses={bccAddresses}
315
- onChange={setBccAddresses}
256
+ label="To"
257
+ addresses={toAddresses}
258
+ onChange={setToAddresses}
259
+ placeholder="Recipients"
316
260
  />
317
- )}
318
- <SubjectField value={subject} onChange={setSubject} />
319
- </div>
261
+ }
262
+ cc={
263
+ showCc ? (
264
+ <AddressField
265
+ label="Cc"
266
+ addresses={ccAddresses}
267
+ onChange={setCcAddresses}
268
+ />
269
+ ) : undefined
270
+ }
271
+ bcc={
272
+ showBcc ? (
273
+ <AddressField
274
+ label="Bcc"
275
+ addresses={bccAddresses}
276
+ onChange={setBccAddresses}
277
+ />
278
+ ) : undefined
279
+ }
280
+ subject={<ComposeSubjectField value={subject} onChange={setSubject} />}
281
+ onShowCc={() => setShowCc(true)}
282
+ onShowBcc={() => setShowBcc(true)}
283
+ />
320
284
  );
321
285
  };
322
286
 
@@ -733,7 +697,7 @@ export const ComposeForm = ({
733
697
  ) : undefined
734
698
  }
735
699
  header={
736
- <ComposeHeader
700
+ <WiredComposeHeader
737
701
  selectedAccountId={selectedAccountId}
738
702
  onAccountChange={handleAccountChange}
739
703
  toAddresses={toAddresses}
@@ -1,49 +1,24 @@
1
+ import { ComposeSmtpMissingBanner as Banner } from "@remit/ui";
1
2
  import { useNavigate } from "@tanstack/react-router";
2
- import { AlertTriangle, ArrowRight } from "lucide-react";
3
3
 
4
4
  interface ComposeSmtpMissingBannerProps {
5
5
  accountId: string;
6
6
  }
7
7
 
8
- /**
9
- * Non-dismissible banner shown above the compose form when the selected
10
- * account has no SMTP host configured. Pairs with disabling the Send
11
- * button so the user has a single, factual explanation of why sending
12
- * is blocked. See issue #196.
13
- */
8
+ /** Sends "Configure SMTP" to the account's settings panel. */
14
9
  export const ComposeSmtpMissingBanner = ({
15
10
  accountId,
16
11
  }: ComposeSmtpMissingBannerProps) => {
17
12
  const navigate = useNavigate();
18
13
 
19
14
  return (
20
- <div
21
- role="alert"
22
- data-testid="compose-smtp-missing-banner"
23
- className="flex items-start gap-3 border-b border-warning/50 bg-warning/10 px-3 py-2"
24
- >
25
- <AlertTriangle
26
- className="size-5 shrink-0 mt-0.5 text-warning"
27
- aria-hidden="true"
28
- />
29
- <div className="flex-1 min-w-0">
30
- <p className="text-sm font-medium text-warning">
31
- This account can't send mail until SMTP is configured.
32
- </p>
33
- <button
34
- type="button"
35
- onClick={() => {
36
- navigate({
37
- to: "/settings/accounts",
38
- search: { editAccountId: accountId, focusSmtp: true },
39
- });
40
- }}
41
- className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-warning hover:underline"
42
- >
43
- Configure SMTP
44
- <ArrowRight className="size-3" aria-hidden="true" />
45
- </button>
46
- </div>
47
- </div>
15
+ <Banner
16
+ onConfigure={() => {
17
+ navigate({
18
+ to: "/settings/accounts",
19
+ search: { editAccountId: accountId, focusSmtp: true },
20
+ });
21
+ }}
22
+ />
48
23
  );
49
24
  };
@@ -1,8 +1,8 @@
1
1
  import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { ConfirmDialog } from "@remit/ui";
2
3
  import { useQuery } from "@tanstack/react-query";
3
4
  import { useCallback, useRef, useState } from "react";
4
5
  import { Drawer } from "vaul";
5
- import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
6
6
  import { ErrorState } from "@/components/ui/ErrorState";
7
7
  import { ComposeForm } from "./ComposeForm";
8
8
  import { useCompose } from "./ComposeProvider";
@@ -1,6 +1,7 @@
1
1
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
2
  import {
3
3
  Banner,
4
+ ConfirmDialog,
4
5
  type Density,
5
6
  deriveIsMultiSelectMode,
6
7
  type MessageListFilter,
@@ -17,7 +18,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
17
18
  import { Search } from "lucide-react";
18
19
  import type { RefObject } from "react";
19
20
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
20
- import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
21
21
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
22
22
  import { formatErrorMessage } from "@/components/ui/ErrorState";
23
23
  import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
@@ -16,7 +16,12 @@
16
16
  * are not rendered: focus stops moving, the highlight disappears, and the next
17
17
  * verb acts on a message the user cannot see.
18
18
  */
19
- import { SelectionTopBar, useListCursor, type Verb } from "@remit/ui";
19
+ import {
20
+ ConfirmDialog,
21
+ SelectionTopBar,
22
+ useListCursor,
23
+ type Verb,
24
+ } from "@remit/ui";
20
25
  import {
21
26
  createContext,
22
27
  type ReactNode,
@@ -28,7 +33,6 @@ import {
28
33
  useRef,
29
34
  useState,
30
35
  } from "react";
31
- import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
32
36
  import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
33
37
  import { useIsDesktop } from "@/hooks/useMediaQuery";
34
38
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
@@ -1,5 +1,5 @@
1
+ import { ConfirmDialog } from "@remit/ui";
1
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
- import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
3
3
  import { deleteLabelConfirmCopy } from "@/lib/organize/label-delete-copy";
4
4
 
5
5
  /**
@@ -7,6 +7,7 @@ import type {
7
7
  import {
8
8
  Banner,
9
9
  Button,
10
+ ConfirmDialog,
10
11
  Input,
11
12
  labelColorOptions,
12
13
  Select,
@@ -16,7 +17,6 @@ import { useQuery } from "@tanstack/react-query";
16
17
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
17
18
  import { useState } from "react";
18
19
  import { LabelsList } from "@/components/settings/LabelsList";
19
- import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
20
20
  import { ErrorState } from "@/components/ui/ErrorState";
21
21
  import {
22
22
  useCreateLabel,