@remit/ui 0.0.68 → 0.0.70
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 +1 -1
- package/src/components/folder-manage-actions.render.test.ts +51 -0
- package/src/components/folder-manage-actions.stories.tsx +94 -0
- package/src/components/folder-manage-actions.tsx +54 -0
- package/src/components/folder-manager.render.test.ts +80 -0
- package/src/components/folder-manager.stories.tsx +184 -0
- package/src/components/folder-manager.tsx +77 -0
- package/src/components/folder-rename-dialog.render.test.ts +51 -0
- package/src/components/folder-rename-dialog.stories.tsx +66 -0
- package/src/components/folder-rename-dialog.tsx +95 -0
- package/src/components/folder-row.render.test.ts +7 -0
- package/src/components/folder-row.tsx +10 -1
- package/src/components/folder-tree-picker.stories.tsx +12 -25
- package/src/components/folder-tree-picker.tsx +13 -4
- package/src/components/rescue-from-spam-flow.render.test.ts +4 -4
- package/src/components/rescue-from-spam-flow.stories.tsx +9 -7
- package/src/components/rescue-from-spam-flow.tsx +20 -16
- package/src/components/selection-wizard.render.test.ts +14 -7
- package/src/components/selection-wizard.tsx +15 -15
- package/src/index.ts +14 -6
- package/src/components/move-mailbox-picker.create.test.ts +0 -210
- package/src/components/move-mailbox-picker.render.test.ts +0 -113
- package/src/components/move-mailbox-picker.stories.tsx +0 -313
- package/src/components/move-mailbox-picker.tsx +0 -404
|
@@ -1,404 +0,0 @@
|
|
|
1
|
-
import { Search } from "lucide-react";
|
|
2
|
-
import {
|
|
3
|
-
type KeyboardEvent as ReactKeyboardEvent,
|
|
4
|
-
useCallback,
|
|
5
|
-
useEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
useRef,
|
|
8
|
-
useState,
|
|
9
|
-
} from "react";
|
|
10
|
-
import { isAbortError } from "../lib/abort.js";
|
|
11
|
-
import { cn } from "../lib/cn.js";
|
|
12
|
-
import { Input } from "./input.js";
|
|
13
|
-
|
|
14
|
-
export interface MoveMailboxOption {
|
|
15
|
-
/** Stable identity passed back to `onSelect`. */
|
|
16
|
-
id: string;
|
|
17
|
-
/** Display label shown in the row and matched against the search query. */
|
|
18
|
-
label: string;
|
|
19
|
-
/**
|
|
20
|
-
* The message's current folder. Rendered as a non-selectable row so the
|
|
21
|
-
* user sees where the message lives but can't move it onto itself.
|
|
22
|
-
*/
|
|
23
|
-
isCurrent?: boolean;
|
|
24
|
-
/**
|
|
25
|
-
* Optional secondary string matched alongside `label` (e.g. the full
|
|
26
|
-
* folder path so "gmail/" narrows nested folders). Never displayed.
|
|
27
|
-
*/
|
|
28
|
-
searchValue?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface MoveMailboxPickerLabels {
|
|
32
|
-
/** Search input placeholder. */
|
|
33
|
-
searchPlaceholder?: string;
|
|
34
|
-
/** Accessible label for the search input. */
|
|
35
|
-
searchAriaLabel?: string;
|
|
36
|
-
/** Accessible label for the listbox. */
|
|
37
|
-
listAriaLabel?: string;
|
|
38
|
-
/** Suffix announced for the current folder, e.g. `(current folder)`. */
|
|
39
|
-
currentSuffix?: string;
|
|
40
|
-
/** Inline tag shown on the current folder row. */
|
|
41
|
-
currentTag?: string;
|
|
42
|
-
/** Builds the empty-state message for a query that matches nothing. */
|
|
43
|
-
emptyMessage?: (query: string) => string;
|
|
44
|
-
/** Builds the accessible label for a selectable row, e.g. `Move to X`. */
|
|
45
|
-
optionLabel?: (label: string) => string;
|
|
46
|
-
/** Builds the label for the create-and-move row, e.g. `Create "Receipts"`. */
|
|
47
|
-
createLabel?: (query: string) => string;
|
|
48
|
-
/** Shown on the create row while the folder is being created. */
|
|
49
|
-
createPending?: string;
|
|
50
|
-
/** Shown when creating the folder fails. */
|
|
51
|
-
createError?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface MoveMailboxPickerProps {
|
|
55
|
-
/**
|
|
56
|
-
* Destinations to show, already filtered, ordered and labeled by the app.
|
|
57
|
-
* The kit owns only search, focus and keyboard — never data shaping.
|
|
58
|
-
*/
|
|
59
|
-
mailboxes: readonly MoveMailboxOption[];
|
|
60
|
-
onSelect: (mailboxId: string) => void;
|
|
61
|
-
/**
|
|
62
|
-
* Create a folder named by the current search query. When provided and the
|
|
63
|
-
* query names no existing folder, a create-and-move row is offered at the
|
|
64
|
-
* bottom of the list; resolving it — once the mail server confirms the folder
|
|
65
|
-
* — yields the new folder, which is selected (moved into). The picker aborts
|
|
66
|
-
* the passed signal on unmount, so a folder that confirms after the picker is
|
|
67
|
-
* closed never fires the move. Absent means no create affordance renders.
|
|
68
|
-
*/
|
|
69
|
-
onCreateFolder?: (
|
|
70
|
-
name: string,
|
|
71
|
-
signal?: AbortSignal,
|
|
72
|
-
) => Promise<MoveMailboxOption>;
|
|
73
|
-
/**
|
|
74
|
-
* Called when the user dismisses the picker via Escape. Trigger consumers
|
|
75
|
-
* use this to close their popover/drawer; the picker never owns
|
|
76
|
-
* presentation, so it cannot close itself without help.
|
|
77
|
-
*/
|
|
78
|
-
onCancel?: () => void;
|
|
79
|
-
/**
|
|
80
|
-
* Mobile callers (bottom-sheet) pass `autoFocus` to focus the search input
|
|
81
|
-
* as soon as the sheet opens — keyboard accessory + immediate filter typing
|
|
82
|
-
* without an extra tap. Desktop dropdowns leave focus on the trigger so
|
|
83
|
-
* click-outside dismissal stays predictable.
|
|
84
|
-
*/
|
|
85
|
-
autoFocus?: boolean;
|
|
86
|
-
labels?: MoveMailboxPickerLabels;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const ROW_BASE =
|
|
90
|
-
"w-full text-left px-3 py-2.5 min-h-11 flex items-center gap-2 transition-colors text-sm rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
|
|
91
|
-
|
|
92
|
-
const defaultLabels: Required<MoveMailboxPickerLabels> = {
|
|
93
|
-
searchPlaceholder: "Move to…",
|
|
94
|
-
searchAriaLabel: "Filter folders",
|
|
95
|
-
listAriaLabel: "Destination folders",
|
|
96
|
-
currentSuffix: "(current folder)",
|
|
97
|
-
currentTag: "current",
|
|
98
|
-
emptyMessage: (query) => `No folders match "${query}"`,
|
|
99
|
-
optionLabel: (label) => `Move to ${label}`,
|
|
100
|
-
createLabel: (query) => `Create "${query}"`,
|
|
101
|
-
createPending: "Creating folder…",
|
|
102
|
-
createError: "Couldn't create that folder. Please try again.",
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const findFirstSelectable = (options: readonly MoveMailboxOption[]): number => {
|
|
106
|
-
for (let i = 0; i < options.length; i += 1) {
|
|
107
|
-
if (!options[i]?.isCurrent) return i;
|
|
108
|
-
}
|
|
109
|
-
return -1;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const findLastSelectable = (options: readonly MoveMailboxOption[]): number => {
|
|
113
|
-
for (let i = options.length - 1; i >= 0; i -= 1) {
|
|
114
|
-
if (!options[i]?.isCurrent) return i;
|
|
115
|
-
}
|
|
116
|
-
return -1;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const findNextSelectable = (
|
|
120
|
-
options: readonly MoveMailboxOption[],
|
|
121
|
-
from: number,
|
|
122
|
-
step: 1 | -1,
|
|
123
|
-
): number => {
|
|
124
|
-
const count = options.length;
|
|
125
|
-
if (count <= 0) return -1;
|
|
126
|
-
const start = from < 0 ? (step === 1 ? -1 : count) : from;
|
|
127
|
-
for (let offset = 1; offset <= count; offset += 1) {
|
|
128
|
-
const candidate = (((start + step * offset) % count) + count) % count;
|
|
129
|
-
if (!options[candidate]?.isCurrent) return candidate;
|
|
130
|
-
}
|
|
131
|
-
return -1;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const matchesQuery = (option: MoveMailboxOption, query: string): boolean => {
|
|
135
|
-
if (option.label.toLowerCase().includes(query)) return true;
|
|
136
|
-
return option.searchValue?.toLowerCase().includes(query) ?? false;
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Move-to-folder destination picker: an always-on search input over a
|
|
141
|
-
* roving-focus listbox. Data-agnostic — the app supplies pre-shaped,
|
|
142
|
-
* pre-ordered options and performs the move in `onSelect`; the kit owns search
|
|
143
|
-
* filtering, keyboard navigation and ARIA structure.
|
|
144
|
-
*/
|
|
145
|
-
export const MoveMailboxPicker = ({
|
|
146
|
-
mailboxes,
|
|
147
|
-
onSelect,
|
|
148
|
-
onCreateFolder,
|
|
149
|
-
onCancel,
|
|
150
|
-
autoFocus = false,
|
|
151
|
-
labels,
|
|
152
|
-
}: MoveMailboxPickerProps) => {
|
|
153
|
-
const text = { ...defaultLabels, ...labels };
|
|
154
|
-
const [query, setQuery] = useState("");
|
|
155
|
-
const [creating, setCreating] = useState(false);
|
|
156
|
-
const [createError, setCreateError] = useState<string>();
|
|
157
|
-
const [focusedIndex, setFocusedIndex] = useState<number>(() =>
|
|
158
|
-
findFirstSelectable(mailboxes),
|
|
159
|
-
);
|
|
160
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
161
|
-
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
162
|
-
// The create waits for the mail server to confirm the folder; abort it when
|
|
163
|
-
// the picker unmounts (its popover/drawer closes) so a late confirmation never
|
|
164
|
-
// fires the move after the picker is gone.
|
|
165
|
-
const createAbort = useRef<AbortController | null>(null);
|
|
166
|
-
useEffect(() => () => createAbort.current?.abort(), []);
|
|
167
|
-
|
|
168
|
-
useEffect(() => {
|
|
169
|
-
if (autoFocus) inputRef.current?.focus();
|
|
170
|
-
}, [autoFocus]);
|
|
171
|
-
|
|
172
|
-
const trimmedQuery = query.trim().toLowerCase();
|
|
173
|
-
const filtered = useMemo(() => {
|
|
174
|
-
if (!trimmedQuery) return [...mailboxes];
|
|
175
|
-
return mailboxes.filter((mailbox) => matchesQuery(mailbox, trimmedQuery));
|
|
176
|
-
}, [mailboxes, trimmedQuery]);
|
|
177
|
-
|
|
178
|
-
useEffect(() => {
|
|
179
|
-
setFocusedIndex((current) => {
|
|
180
|
-
if (
|
|
181
|
-
current >= 0 &&
|
|
182
|
-
current < filtered.length &&
|
|
183
|
-
!filtered[current]?.isCurrent
|
|
184
|
-
) {
|
|
185
|
-
return current;
|
|
186
|
-
}
|
|
187
|
-
return findFirstSelectable(filtered);
|
|
188
|
-
});
|
|
189
|
-
}, [filtered]);
|
|
190
|
-
|
|
191
|
-
useEffect(() => {
|
|
192
|
-
if (focusedIndex < 0) return;
|
|
193
|
-
const node = optionRefs.current[focusedIndex];
|
|
194
|
-
if (!node) return;
|
|
195
|
-
// Only steal focus if the user is already navigating the list — moving
|
|
196
|
-
// focus while they type in the filter input would trap them. The search
|
|
197
|
-
// input keeps focus until the first ArrowDown.
|
|
198
|
-
if (document.activeElement === inputRef.current) return;
|
|
199
|
-
node.focus();
|
|
200
|
-
}, [focusedIndex]);
|
|
201
|
-
|
|
202
|
-
const handleConfirm = useCallback(() => {
|
|
203
|
-
if (focusedIndex < 0) return;
|
|
204
|
-
const target = filtered[focusedIndex];
|
|
205
|
-
if (!target || target.isCurrent) return;
|
|
206
|
-
onSelect(target.id);
|
|
207
|
-
}, [focusedIndex, filtered, onSelect]);
|
|
208
|
-
|
|
209
|
-
const showCreate =
|
|
210
|
-
!!onCreateFolder &&
|
|
211
|
-
trimmedQuery.length > 0 &&
|
|
212
|
-
!mailboxes.some((mailbox) => mailbox.label.toLowerCase() === trimmedQuery);
|
|
213
|
-
|
|
214
|
-
const handleCreate = useCallback(() => {
|
|
215
|
-
if (!onCreateFolder) return;
|
|
216
|
-
const name = query.trim();
|
|
217
|
-
if (name === "") return;
|
|
218
|
-
setCreating(true);
|
|
219
|
-
setCreateError(undefined);
|
|
220
|
-
createAbort.current?.abort();
|
|
221
|
-
const controller = new AbortController();
|
|
222
|
-
createAbort.current = controller;
|
|
223
|
-
onCreateFolder(name, controller.signal)
|
|
224
|
-
.then((folder) => {
|
|
225
|
-
onSelect(folder.id);
|
|
226
|
-
setCreating(false);
|
|
227
|
-
})
|
|
228
|
-
.catch((error: unknown) => {
|
|
229
|
-
if (isAbortError(error)) return;
|
|
230
|
-
setCreateError(
|
|
231
|
-
error instanceof Error ? error.message : text.createError,
|
|
232
|
-
);
|
|
233
|
-
setCreating(false);
|
|
234
|
-
});
|
|
235
|
-
}, [onCreateFolder, query, onSelect, text.createError]);
|
|
236
|
-
|
|
237
|
-
const handleListKeyDown = useCallback(
|
|
238
|
-
(event: ReactKeyboardEvent<HTMLElement>) => {
|
|
239
|
-
switch (event.key) {
|
|
240
|
-
case "ArrowDown":
|
|
241
|
-
event.preventDefault();
|
|
242
|
-
setFocusedIndex((current) =>
|
|
243
|
-
findNextSelectable(filtered, current, 1),
|
|
244
|
-
);
|
|
245
|
-
return;
|
|
246
|
-
case "ArrowUp":
|
|
247
|
-
event.preventDefault();
|
|
248
|
-
setFocusedIndex((current) =>
|
|
249
|
-
findNextSelectable(filtered, current, -1),
|
|
250
|
-
);
|
|
251
|
-
return;
|
|
252
|
-
case "Home":
|
|
253
|
-
event.preventDefault();
|
|
254
|
-
setFocusedIndex(findFirstSelectable(filtered));
|
|
255
|
-
return;
|
|
256
|
-
case "End":
|
|
257
|
-
event.preventDefault();
|
|
258
|
-
setFocusedIndex(findLastSelectable(filtered));
|
|
259
|
-
return;
|
|
260
|
-
case "Enter":
|
|
261
|
-
case " ":
|
|
262
|
-
event.preventDefault();
|
|
263
|
-
handleConfirm();
|
|
264
|
-
return;
|
|
265
|
-
case "Escape":
|
|
266
|
-
event.preventDefault();
|
|
267
|
-
onCancel?.();
|
|
268
|
-
return;
|
|
269
|
-
default:
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
},
|
|
273
|
-
[filtered, handleConfirm, onCancel],
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
const handleInputKeyDown = useCallback(
|
|
277
|
-
(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
278
|
-
if (event.key === "ArrowDown") {
|
|
279
|
-
event.preventDefault();
|
|
280
|
-
if (filtered.length === 0) return;
|
|
281
|
-
const target = optionRefs.current[focusedIndex >= 0 ? focusedIndex : 0];
|
|
282
|
-
target?.focus();
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
if (event.key === "Escape") {
|
|
286
|
-
event.preventDefault();
|
|
287
|
-
onCancel?.();
|
|
288
|
-
}
|
|
289
|
-
},
|
|
290
|
-
[filtered.length, focusedIndex, onCancel],
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
return (
|
|
294
|
-
<div className="flex flex-col">
|
|
295
|
-
<Input
|
|
296
|
-
variant="inline"
|
|
297
|
-
className="border-b border-line px-3 py-2"
|
|
298
|
-
icon={<Search className="size-4" aria-hidden="true" />}
|
|
299
|
-
ref={inputRef}
|
|
300
|
-
type="search"
|
|
301
|
-
value={query}
|
|
302
|
-
onChange={(event) => setQuery(event.target.value)}
|
|
303
|
-
onKeyDown={handleInputKeyDown}
|
|
304
|
-
placeholder={text.searchPlaceholder}
|
|
305
|
-
aria-label={text.searchAriaLabel}
|
|
306
|
-
/>
|
|
307
|
-
<div
|
|
308
|
-
className="flex-1 overflow-y-auto py-1"
|
|
309
|
-
role="listbox"
|
|
310
|
-
aria-label={text.listAriaLabel}
|
|
311
|
-
onKeyDown={handleListKeyDown}
|
|
312
|
-
>
|
|
313
|
-
{filtered.length === 0 ? (
|
|
314
|
-
<div className="px-3 py-3 text-sm text-fg-muted" aria-live="polite">
|
|
315
|
-
{text.emptyMessage(query)}
|
|
316
|
-
</div>
|
|
317
|
-
) : (
|
|
318
|
-
filtered.map((mailbox, idx) => {
|
|
319
|
-
const isCurrent = mailbox.isCurrent ?? false;
|
|
320
|
-
const isFocused = idx === focusedIndex;
|
|
321
|
-
// The current folder is a "you are here" marker, not a target —
|
|
322
|
-
// rendered as a static option (never a disabled control) so the
|
|
323
|
-
// user can see where the message lives without a dead button.
|
|
324
|
-
// Selectable folders are real <button> options. Each interactive
|
|
325
|
-
// element IS the listbox option: a role="option" <div> wrapping a
|
|
326
|
-
// separately-interactive <button> is invalid ARIA.
|
|
327
|
-
if (isCurrent) {
|
|
328
|
-
return (
|
|
329
|
-
<div key={mailbox.id}>
|
|
330
|
-
{/* biome-ignore lint/a11y/useFocusableInteractive: focus managed by the parent listbox component */}
|
|
331
|
-
<div
|
|
332
|
-
role="option"
|
|
333
|
-
aria-selected={false}
|
|
334
|
-
aria-current="true"
|
|
335
|
-
aria-label={`${mailbox.label} ${text.currentSuffix}`}
|
|
336
|
-
className={cn(ROW_BASE, "opacity-60 bg-surface-sunken/40")}
|
|
337
|
-
>
|
|
338
|
-
<span className="truncate flex-1">{mailbox.label}</span>
|
|
339
|
-
<span className="text-xs text-fg-muted shrink-0">
|
|
340
|
-
{text.currentTag}
|
|
341
|
-
</span>
|
|
342
|
-
</div>
|
|
343
|
-
</div>
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
return (
|
|
347
|
-
<div key={mailbox.id}>
|
|
348
|
-
<button
|
|
349
|
-
ref={(node) => {
|
|
350
|
-
optionRefs.current[idx] = node;
|
|
351
|
-
}}
|
|
352
|
-
type="button"
|
|
353
|
-
role="option"
|
|
354
|
-
aria-selected={false}
|
|
355
|
-
tabIndex={isFocused ? 0 : -1}
|
|
356
|
-
onClick={() => onSelect(mailbox.id)}
|
|
357
|
-
onFocus={() => setFocusedIndex(idx)}
|
|
358
|
-
aria-label={text.optionLabel(mailbox.label)}
|
|
359
|
-
className={cn(ROW_BASE, "hover:bg-surface-raised")}
|
|
360
|
-
>
|
|
361
|
-
<span className="truncate flex-1">{mailbox.label}</span>
|
|
362
|
-
</button>
|
|
363
|
-
</div>
|
|
364
|
-
);
|
|
365
|
-
})
|
|
366
|
-
)}
|
|
367
|
-
</div>
|
|
368
|
-
{showCreate && (
|
|
369
|
-
<div className="border-t border-line p-1">
|
|
370
|
-
<button
|
|
371
|
-
type="button"
|
|
372
|
-
onClick={handleCreate}
|
|
373
|
-
disabled={creating}
|
|
374
|
-
className={cn(
|
|
375
|
-
ROW_BASE,
|
|
376
|
-
"font-medium text-accent-2 hover:bg-surface-raised disabled:opacity-60",
|
|
377
|
-
)}
|
|
378
|
-
>
|
|
379
|
-
<span className="truncate">
|
|
380
|
-
{creating ? text.createPending : text.createLabel(query.trim())}
|
|
381
|
-
</span>
|
|
382
|
-
</button>
|
|
383
|
-
{createError && (
|
|
384
|
-
<p className="px-3 py-1 text-xs text-danger" role="alert">
|
|
385
|
-
{createError}
|
|
386
|
-
</p>
|
|
387
|
-
)}
|
|
388
|
-
</div>
|
|
389
|
-
)}
|
|
390
|
-
</div>
|
|
391
|
-
);
|
|
392
|
-
};
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Pure selection/filter helpers exposed for unit testing the roving-focus and
|
|
396
|
-
* search logic without a DOM. Component consumers should use
|
|
397
|
-
* {@link MoveMailboxPicker} instead.
|
|
398
|
-
*/
|
|
399
|
-
export const moveMailboxPickerInternals = {
|
|
400
|
-
findFirstSelectable,
|
|
401
|
-
findLastSelectable,
|
|
402
|
-
findNextSelectable,
|
|
403
|
-
matchesQuery,
|
|
404
|
-
};
|