@remit/ui 0.0.138 → 0.0.140

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.
@@ -0,0 +1,309 @@
1
+ import { AlertTriangle, Loader2 } from "lucide-react";
2
+ import { useEffect, useRef } from "react";
3
+ import type { FolderTreeNode } from "../lib/folder-tree.js";
4
+ import { Banner } from "./banner.js";
5
+ import { Button } from "./button.js";
6
+ import { Dialog } from "./dialog.js";
7
+ import {
8
+ FolderTreePicker,
9
+ type FolderTreePickerProps,
10
+ } from "./folder-tree-picker.js";
11
+
12
+ /** Why the action was refused. Never re-derived from live state while open. */
13
+ export type PromptReason = "none" | "stale" | "unconfirmed";
14
+
15
+ /** What the confirm completes. Decides the verb and the count in every label. */
16
+ export type PromptAction =
17
+ | { kind: "delete"; count: number }
18
+ | { kind: "emptyTrash" };
19
+
20
+ export type PromptPhase =
21
+ | { kind: "choosing" }
22
+ | { kind: "appointing" }
23
+ | { kind: "acting" }
24
+ | { kind: "appoint-failed"; cause: "generic" | "mailbox-pending" };
25
+
26
+ export interface RoleAppointmentPromptCopy {
27
+ title: string;
28
+ description: string;
29
+ pickerPrompt: string;
30
+ confirmLabel: string;
31
+ cancelLabel: string;
32
+ }
33
+
34
+ export interface RoleAppointmentPromptCopyContext {
35
+ /** `unconfirmed`: the folder reader guessed from its name. */
36
+ trashFolderLabel?: string;
37
+ /** `stale`: the folder that vanished from the mail server. */
38
+ staleFolderLabel?: string;
39
+ /** The chosen folder's message count, so the confirm re-words with it. */
40
+ selectedCount?: number;
41
+ }
42
+
43
+ const plural = (count: number): string =>
44
+ count === 1 ? "message" : "messages";
45
+
46
+ const quantified = (count: number): string =>
47
+ `${count.toLocaleString()} ${plural(count)}`;
48
+
49
+ /**
50
+ * What the confirm completes. Empty Trash names a count only where the user is
51
+ * confirming a guess — that is the framing whose confirm expunges, and the
52
+ * count moves with the row they picked.
53
+ */
54
+ const actionSuffix = (
55
+ action: PromptAction,
56
+ selectedCount: number | undefined,
57
+ ): string => {
58
+ if (action.kind === "delete") return `delete ${quantified(action.count)}`;
59
+ if (selectedCount === undefined) return "empty it";
60
+ return `empty ${quantified(selectedCount)}`;
61
+ };
62
+
63
+ /** "Nothing changed", in the tense of the verb that was refused. */
64
+ const nothingHappened = (action: PromptAction): string =>
65
+ action.kind === "delete"
66
+ ? "Nothing has been deleted."
67
+ : "Nothing has been emptied.";
68
+
69
+ /**
70
+ * Every word the prompt says, as a pure function of the refusal — mirroring
71
+ * `deleteConfirmationCopy`, so the stories render what the app renders and each
72
+ * branch is testable without a DOM.
73
+ */
74
+ export const roleAppointmentPromptCopy = (
75
+ reason: PromptReason,
76
+ action: PromptAction,
77
+ context: RoleAppointmentPromptCopyContext = {},
78
+ ): RoleAppointmentPromptCopy => {
79
+ const { trashFolderLabel, staleFolderLabel, selectedCount } = context;
80
+ const stopped = nothingHappened(action);
81
+
82
+ if (reason === "none") {
83
+ return {
84
+ title: "No folder is set as Trash",
85
+ description: `${stopped} reader files deleted mail in the folder you set as Trash, and this account has none.`,
86
+ pickerPrompt: "Which folder does this account use for deleted mail?",
87
+ confirmLabel: `Set as Trash and ${actionSuffix(action, undefined)}`,
88
+ cancelLabel: "Cancel",
89
+ };
90
+ }
91
+
92
+ if (reason === "stale") {
93
+ return {
94
+ title: "The Trash folder you chose is gone",
95
+ description: staleFolderLabel
96
+ ? `${stopped} You set ${staleFolderLabel} as this account's Trash and it is no longer on the mail server — another mail app may have renamed or removed it.`
97
+ : `${stopped} The folder you set as this account's Trash is no longer on the mail server — another mail app may have renamed or removed it.`,
98
+ pickerPrompt: "Which folder should reader use instead?",
99
+ confirmLabel: `Set as Trash and ${actionSuffix(action, undefined)}`,
100
+ cancelLabel: "Cancel",
101
+ };
102
+ }
103
+
104
+ const irreversible =
105
+ "Emptying a folder erases everything in it from the mail server, and that cannot be restored.";
106
+ return {
107
+ title: "Confirm this account's Trash folder",
108
+ description: trashFolderLabel
109
+ ? `${stopped} reader files this account's deleted mail in ${trashFolderLabel} because of its name — you never chose it, and the mail server doesn't mark it as Trash. ${irreversible}`
110
+ : `${stopped} reader files this account's deleted mail in a folder it matched by name — you never chose it, and the mail server doesn't mark it as Trash. ${irreversible}`,
111
+ pickerPrompt: trashFolderLabel
112
+ ? `Confirm ${trashFolderLabel}, or pick the folder this account really uses.`
113
+ : "Pick the folder this account really uses for deleted mail.",
114
+ confirmLabel: `Set as Trash and ${actionSuffix(action, selectedCount)}`,
115
+ cancelLabel: "Cancel",
116
+ };
117
+ };
118
+
119
+ interface PendingCopy {
120
+ headline: string;
121
+ status: string;
122
+ }
123
+
124
+ const pendingCopy = (
125
+ phase: PromptPhase,
126
+ action: PromptAction,
127
+ folderLabel: string,
128
+ ): PendingCopy => {
129
+ if (phase.kind === "appointing") {
130
+ return { headline: "Setting the Trash folder…", status: folderLabel };
131
+ }
132
+ if (action.kind === "delete") {
133
+ return {
134
+ headline: `Deleting ${quantified(action.count)}…`,
135
+ status: `Moving them to ${folderLabel}`,
136
+ };
137
+ }
138
+ return {
139
+ headline: `Emptying ${folderLabel}…`,
140
+ status: "This can't be undone.",
141
+ };
142
+ };
143
+
144
+ const failureCopy = (
145
+ cause: "generic" | "mailbox-pending",
146
+ action: PromptAction,
147
+ folderLabel: string,
148
+ ): string =>
149
+ cause === "mailbox-pending"
150
+ ? `${folderLabel} is still being created on the mail server. Wait for it to finish, then try again.`
151
+ : `Couldn't set that folder as Trash. ${nothingHappened(action)} Please try again.`;
152
+
153
+ export interface RoleAppointmentPromptProps {
154
+ open: boolean;
155
+ reason: PromptReason;
156
+ action: PromptAction;
157
+ /** The account's real folders, with counts — a real Trash is told from an empty look-alike by its count. */
158
+ folders: readonly FolderTreeNode[];
159
+ delimiter: string;
160
+ /** `unconfirmed`: the folder reader guessed from its name. */
161
+ trashFolderLabel?: string;
162
+ /** `stale`: the folder that vanished from the mail server. */
163
+ staleFolderLabel?: string;
164
+ /** Only on instances holding more than one account. */
165
+ accountEmail?: string;
166
+ phase: PromptPhase;
167
+ selectedId?: string;
168
+ onSelect: (mailboxId: string) => void;
169
+ /** Wired for `none` only: a repair must not make a second empty Trash. */
170
+ onCreateFolder?: FolderTreePickerProps["onCreateFolder"];
171
+ onConfirm: (mailboxId: string) => void;
172
+ onCancel: () => void;
173
+ }
174
+
175
+ /**
176
+ * The refused action's own dialog: it says what stopped and that nothing
177
+ * changed, offers the account's real folders with their counts, and completes
178
+ * the original action from the same confirm that appoints the folder.
179
+ *
180
+ * Controlled — the app owns both writes. The confirm mounts only once a folder
181
+ * is chosen, so a confirm is never on screen disabled; Cancel is always there,
182
+ * so there is always a way out that changes nothing.
183
+ */
184
+ export const RoleAppointmentPrompt = ({
185
+ open,
186
+ reason,
187
+ action,
188
+ folders,
189
+ delimiter,
190
+ trashFolderLabel,
191
+ staleFolderLabel,
192
+ accountEmail,
193
+ phase,
194
+ selectedId,
195
+ onSelect,
196
+ onCreateFolder,
197
+ onConfirm,
198
+ onCancel,
199
+ }: RoleAppointmentPromptProps) => {
200
+ const pendingRef = useRef<HTMLDivElement>(null);
201
+ const pending = phase.kind === "appointing" || phase.kind === "acting";
202
+
203
+ // Escape must not unmount a dialog whose write has already left. Dialog
204
+ // yields Escape to `[data-escape-owner]`, which only holds while the focus
205
+ // is inside it — so the pending body takes the focus the footer gave up.
206
+ useEffect(() => {
207
+ if (!pending) return;
208
+ pendingRef.current?.focus();
209
+ }, [pending]);
210
+
211
+ const selected = folders.find((folder) => folder.id === selectedId);
212
+ const copy = roleAppointmentPromptCopy(reason, action, {
213
+ trashFolderLabel,
214
+ staleFolderLabel,
215
+ selectedCount: selected?.messageCount,
216
+ });
217
+ const expunges = action.kind === "emptyTrash";
218
+
219
+ if (!open) return null;
220
+
221
+ if (pending) {
222
+ const words = pendingCopy(phase, action, selected?.label ?? "");
223
+ return (
224
+ <Dialog open={open} onClose={() => {}} title={copy.title}>
225
+ <div
226
+ ref={pendingRef}
227
+ data-escape-owner
228
+ tabIndex={-1}
229
+ className="flex flex-col items-center gap-3 px-5 py-10 text-center outline-none"
230
+ >
231
+ <Loader2 className="size-8 animate-spin text-accent-2" />
232
+ <p className="text-sm font-medium text-fg">{words.headline}</p>
233
+ <p className="text-xs text-fg-muted" role="status" aria-live="polite">
234
+ {words.status}
235
+ </p>
236
+ </div>
237
+ </Dialog>
238
+ );
239
+ }
240
+
241
+ return (
242
+ <Dialog open={open} onClose={onCancel} title={copy.title}>
243
+ {/* No header dismiss: the footer's Cancel is the way out, which leaves
244
+ the picker's filter field as the dialog's first focusable — initial
245
+ focus belongs there, never on a confirm that erases mail. */}
246
+ <header className="flex items-center gap-2 border-b border-line px-5 py-3">
247
+ <AlertTriangle
248
+ className={`size-4 shrink-0 ${expunges ? "text-danger" : "text-warning"}`}
249
+ />
250
+ <span className="flex-1 text-sm font-semibold text-fg">
251
+ {copy.title}
252
+ </span>
253
+ </header>
254
+ <div className="flex max-h-[70vh] flex-col">
255
+ <div className="space-y-1 px-5 py-4">
256
+ <p className="text-sm text-fg-muted">{copy.description}</p>
257
+ {accountEmail && (
258
+ <p className="text-xs text-fg-subtle">{accountEmail}</p>
259
+ )}
260
+ </div>
261
+ {phase.kind === "appoint-failed" && (
262
+ <div className="px-5 pb-3">
263
+ <Banner tone="danger" variant="soft">
264
+ {failureCopy(
265
+ phase.cause,
266
+ action,
267
+ selected?.label ?? "That folder",
268
+ )}
269
+ </Banner>
270
+ </div>
271
+ )}
272
+ <p className="px-5 pb-2 text-sm text-fg-muted">{copy.pickerPrompt}</p>
273
+ <div className="flex h-[26rem] min-h-0 flex-1 overflow-hidden">
274
+ <FolderTreePicker
275
+ folders={folders}
276
+ selectedId={selectedId}
277
+ delimiter={delimiter}
278
+ onSelect={onSelect}
279
+ onCreateFolder={reason === "none" ? onCreateFolder : undefined}
280
+ onCancel={onCancel}
281
+ labels={{
282
+ treeAriaLabel: "Folders on this account",
283
+ optionLabel: (folder) =>
284
+ `Set ${folder.label}${
285
+ folder.messageCount === undefined
286
+ ? ""
287
+ : `, ${folder.messageCount} messages`
288
+ }, as Trash`,
289
+ }}
290
+ />
291
+ </div>
292
+ </div>
293
+ <footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
294
+ <Button variant="secondary" size="sm" onClick={onCancel}>
295
+ {copy.cancelLabel}
296
+ </Button>
297
+ {selected && (
298
+ <Button
299
+ variant={expunges ? "danger" : "primary"}
300
+ size="sm"
301
+ onClick={() => onConfirm(selected.id)}
302
+ >
303
+ <span className="truncate">{copy.confirmLabel}</span>
304
+ </Button>
305
+ )}
306
+ </footer>
307
+ </Dialog>
308
+ );
309
+ };
@@ -1,17 +1,25 @@
1
1
  import { ChevronDown } from "lucide-react";
2
- import type { ReactNode, SelectHTMLAttributes } from "react";
2
+ import type { ReactNode, Ref, SelectHTMLAttributes } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
4
 
5
5
  export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
6
6
  /** Optional leading icon (lucide-react element). */
7
7
  icon?: ReactNode;
8
+ /** So a surface blocked on this choice can put the cursor on it. */
9
+ ref?: Ref<HTMLSelectElement>;
8
10
  }
9
11
 
10
12
  /**
11
13
  * Native select styled to match Input: same height, hairline border,
12
14
  * sunken surface, focus ring. Options render in <option> children.
13
15
  */
14
- export function Select({ icon, className, children, ...props }: SelectProps) {
16
+ export function Select({
17
+ icon,
18
+ className,
19
+ children,
20
+ ref,
21
+ ...props
22
+ }: SelectProps) {
15
23
  return (
16
24
  <div
17
25
  className={cn(
@@ -22,6 +30,7 @@ export function Select({ icon, className, children, ...props }: SelectProps) {
22
30
  >
23
31
  {icon && <span className="shrink-0 text-fg-subtle">{icon}</span>}
24
32
  <select
33
+ ref={ref}
25
34
  className="min-w-0 flex-1 appearance-none bg-transparent py-0 pr-8 text-fg outline-none"
26
35
  {...props}
27
36
  >
package/src/index.ts CHANGED
@@ -582,9 +582,20 @@ export {
582
582
  export {
583
583
  APPOINTABLE_ROLES,
584
584
  type CandidateFolder,
585
+ type RoleAppointment,
585
586
  RoleAppointmentList,
586
587
  type RoleAppointmentListProps,
588
+ type RoleAppointmentSource,
587
589
  } from "./components/role-appointment-list.js";
590
+ export {
591
+ type PromptAction,
592
+ type PromptPhase,
593
+ type PromptReason,
594
+ RoleAppointmentPrompt,
595
+ type RoleAppointmentPromptCopy,
596
+ type RoleAppointmentPromptProps,
597
+ roleAppointmentPromptCopy,
598
+ } from "./components/role-appointment-prompt.js";
588
599
  export {
589
600
  type RowAction,
590
601
  RowActions,
@@ -840,6 +851,7 @@ export {
840
851
  filterFolderTree,
841
852
  folderAncestors,
842
853
  folderDepth,
854
+ folderLeaf,
843
855
  folderParent,
844
856
  matchesQuery,
845
857
  orderFolderNodes,
@@ -6,6 +6,7 @@ import {
6
6
  filterFolderTree,
7
7
  folderAncestors,
8
8
  folderDepth,
9
+ folderLeaf,
9
10
  folderParent,
10
11
  matchesQuery,
11
12
  orderFolderNodes,
@@ -279,3 +280,21 @@ describe("create rows", () => {
279
280
  ]);
280
281
  });
281
282
  });
283
+
284
+ describe("folderLeaf", () => {
285
+ it("takes the leaf under a dot-delimited namespace", () => {
286
+ assert.equal(folderLeaf("INBOX.Projects.Q3", "."), "Q3");
287
+ });
288
+
289
+ it("takes the leaf under a slash-delimited namespace", () => {
290
+ assert.equal(folderLeaf("INBOX/Sent Messages", "/"), "Sent Messages");
291
+ });
292
+
293
+ it("keeps the whole name when the server reports no delimiter", () => {
294
+ assert.equal(folderLeaf("Projects/Q3", ""), "Projects/Q3");
295
+ });
296
+
297
+ it("keeps a top-level name that carries no delimiter", () => {
298
+ assert.equal(folderLeaf("INBOX", "."), "INBOX");
299
+ });
300
+ });
@@ -14,6 +14,12 @@ export interface FolderTreeNode {
14
14
  * rendered as a marker rather than a disabled control.
15
15
  */
16
16
  isCurrent?: boolean;
17
+ /**
18
+ * How much mail the folder holds. A folder named `Trash` may be an empty
19
+ * look-alike beside the real one, and the count is the only thing that tells
20
+ * them apart. Absent means the surface has no count, not zero.
21
+ */
22
+ messageCount?: number;
17
23
  }
18
24
 
19
25
  export interface FolderTreeRow {
@@ -32,6 +38,14 @@ export type FolderTreeDisplayRow =
32
38
  | { kind: "folder"; row: FolderTreeRow; index: number }
33
39
  | { kind: "create"; parent: FolderTreeNode; depth: number };
34
40
 
41
+ // A server that reports no delimiter has a flat namespace, so the path is its
42
+ // own leaf; splitting on "" would return single characters.
43
+ export const folderLeaf = (path: string, delimiter: string): string => {
44
+ if (delimiter.length === 0) return path;
45
+ const parts = path.split(delimiter);
46
+ return parts[parts.length - 1] || path;
47
+ };
48
+
35
49
  export const folderParent = (path: string, delimiter: string): string => {
36
50
  const cut = path.lastIndexOf(delimiter);
37
51
  return cut === -1 ? "" : path.slice(0, cut);