@remit/ui 0.0.69 → 0.0.71

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.
@@ -1,313 +0,0 @@
1
- import type { Meta, StoryObj } from "@storybook/react";
2
- import { useState } from "react";
3
- import {
4
- type MoveMailboxOption,
5
- MoveMailboxPicker,
6
- } from "./move-mailbox-picker.js";
7
-
8
- const mailboxes: MoveMailboxOption[] = [
9
- { id: "inbox", label: "Inbox", isCurrent: true },
10
- { id: "archive", label: "Archive" },
11
- { id: "trash", label: "Trash" },
12
- { id: "spam", label: "Spam" },
13
- { id: "receipts", label: "Receipts", searchValue: "finance/receipts" },
14
- { id: "travel", label: "Travel", searchValue: "finance/travel" },
15
- { id: "newsletters", label: "Newsletters" },
16
- ];
17
-
18
- const manyMailboxes: MoveMailboxOption[] = [
19
- { id: "inbox", label: "Inbox", isCurrent: true },
20
- ...Array.from({ length: 24 }, (_, i) => ({
21
- id: `folder-${i}`,
22
- label: `Project ${String(i + 1).padStart(2, "0")}`,
23
- })),
24
- ];
25
-
26
- const meta: Meta<typeof MoveMailboxPicker> = {
27
- title: "Mail/MoveMailboxPicker",
28
- component: MoveMailboxPicker,
29
- parameters: { layout: "centered" },
30
- decorators: [
31
- (Story) => (
32
- <div className="w-72 max-h-96 overflow-hidden rounded-md border border-line bg-surface shadow-lg">
33
- <Story />
34
- </div>
35
- ),
36
- ],
37
- };
38
- export default meta;
39
-
40
- type Story = StoryObj<typeof MoveMailboxPicker>;
41
-
42
- const Picker = ({ options }: { options: MoveMailboxOption[] }) => {
43
- const [moved, setMoved] = useState<string | null>(null);
44
- return (
45
- <div className="flex flex-col">
46
- <MoveMailboxPicker mailboxes={options} onSelect={setMoved} />
47
- {moved && (
48
- <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
49
- Moved to {moved}
50
- </p>
51
- )}
52
- </div>
53
- );
54
- };
55
-
56
- export const Default: Story = {
57
- name: "Default (current folder marked)",
58
- render: () => <Picker options={mailboxes} />,
59
- };
60
-
61
- export const ManyMailboxes: Story = {
62
- name: "Many mailboxes (scrolls)",
63
- render: () => <Picker options={manyMailboxes} />,
64
- };
65
-
66
- export const Empty: Story = {
67
- name: "Empty list",
68
- render: () => <Picker options={[]} />,
69
- };
70
-
71
- export const Autofocus: Story = {
72
- name: "Autofocus search (mobile sheet)",
73
- render: () => (
74
- <MoveMailboxPicker mailboxes={mailboxes} onSelect={() => {}} autoFocus />
75
- ),
76
- };
77
-
78
- let createdSeq = 0;
79
- const mockCreateFolder = (name: string): Promise<MoveMailboxOption> =>
80
- new Promise((resolve) => {
81
- createdSeq += 1;
82
- setTimeout(
83
- () => resolve({ id: `created-${createdSeq}`, label: name }),
84
- 400,
85
- );
86
- });
87
-
88
- const CreatePicker = () => {
89
- const [moved, setMoved] = useState<string | null>(null);
90
- return (
91
- <div className="flex flex-col">
92
- <MoveMailboxPicker
93
- mailboxes={mailboxes}
94
- onSelect={setMoved}
95
- onCreateFolder={mockCreateFolder}
96
- />
97
- {moved && (
98
- <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
99
- Moved to {moved}
100
- </p>
101
- )}
102
- </div>
103
- );
104
- };
105
-
106
- /**
107
- * With `onCreateFolder` wired, a search that names no existing folder offers a
108
- * create-and-move row at the bottom. Type e.g. "Taxes", then pick the create
109
- * row — the folder is created and the message moved into it in one step.
110
- */
111
- export const CreateAndMove: Story = {
112
- name: "Create folder from search",
113
- render: () => <CreatePicker />,
114
- };
115
-
116
- /**
117
- * Type a folder name into the search box and press the create-and-move row —
118
- * used by the pending and error stories so each lands in its state without a
119
- * manual click-through.
120
- */
121
- async function typeAndCreate(canvasElement: HTMLElement, folderName: string) {
122
- const setInputValue = Object.getOwnPropertyDescriptor(
123
- HTMLInputElement.prototype,
124
- "value",
125
- )?.set;
126
- const input = canvasElement.querySelector<HTMLInputElement>(
127
- 'input[type="search"]',
128
- );
129
- if (!input) return;
130
- setInputValue?.call(input, folderName);
131
- input.dispatchEvent(new Event("input", { bubbles: true }));
132
- const createButton = Array.from(
133
- canvasElement.querySelectorAll<HTMLButtonElement>("button"),
134
- ).find((button) => button.textContent?.includes(`Create "${folderName}"`));
135
- createButton?.click();
136
- }
137
-
138
- /** Mirrors the web-client wait's honest timeout copy. */
139
- const TIMEOUT_MESSAGE =
140
- "The folder was created but the mail server hasn't confirmed it yet, so nothing was attached to it. It's in your folder list — try again in a moment.";
141
-
142
- const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
143
-
144
- const neverResolvesCreateFolder = (): Promise<MoveMailboxOption> =>
145
- new Promise<MoveMailboxOption>(() => undefined);
146
-
147
- const rejectingCreateFolder =
148
- (message: string) => (): Promise<MoveMailboxOption> =>
149
- Promise.reject(new Error(message));
150
-
151
- /** Rejects the first attempt, resolves the retry — the resume the hook performs. */
152
- const failThenSucceedCreateFolder = () => {
153
- let attempts = 0;
154
- return (name: string): Promise<MoveMailboxOption> => {
155
- attempts += 1;
156
- return attempts === 1
157
- ? Promise.reject(new Error(TIMEOUT_MESSAGE))
158
- : Promise.resolve({ id: "mbx-created", label: name });
159
- };
160
- };
161
-
162
- /** Never resolves on its own; rejects with an AbortError when the signal aborts. */
163
- const abortAwareCreateFolder = (
164
- _name: string,
165
- signal?: AbortSignal,
166
- ): Promise<MoveMailboxOption> =>
167
- new Promise<MoveMailboxOption>((_resolve, reject) => {
168
- signal?.addEventListener("abort", () =>
169
- reject(new DOMException("Aborted", "AbortError")),
170
- );
171
- });
172
-
173
- /**
174
- * The move is a dependent write on the folder: the create-and-move row does not
175
- * resolve until the mail server confirms the folder, so the move never races the
176
- * folder into existence. The wait shows as "Creating folder…".
177
- */
178
- export const CreateFolderInFlight: Story = {
179
- name: "Create folder — waiting for the server",
180
- render: () => (
181
- <MoveMailboxPicker
182
- mailboxes={mailboxes}
183
- onSelect={() => undefined}
184
- onCreateFolder={neverResolvesCreateFolder}
185
- />
186
- ),
187
- play: async ({ canvasElement }) => {
188
- await typeAndCreate(canvasElement, "Taxes");
189
- },
190
- };
191
-
192
- /**
193
- * The folder create failed on the mail server. No move runs; the error is shown
194
- * inline and the create row can be pressed again to retry.
195
- */
196
- export const CreateFolderFailed: Story = {
197
- name: "Create folder — failed (retry)",
198
- render: () => (
199
- <MoveMailboxPicker
200
- mailboxes={mailboxes}
201
- onSelect={() => undefined}
202
- onCreateFolder={rejectingCreateFolder(
203
- "The folder couldn't be created on the mail server. Please try again.",
204
- )}
205
- />
206
- ),
207
- play: async ({ canvasElement }) => {
208
- await typeAndCreate(canvasElement, "Taxes");
209
- },
210
- };
211
-
212
- /**
213
- * The folder create was never confirmed within the wait bound — the timeout is
214
- * named distinctly, and no move runs.
215
- */
216
- export const CreateFolderTimedOut: Story = {
217
- name: "Create folder — timed out (retry)",
218
- render: () => (
219
- <MoveMailboxPicker
220
- mailboxes={mailboxes}
221
- onSelect={() => undefined}
222
- onCreateFolder={rejectingCreateFolder(TIMEOUT_MESSAGE)}
223
- />
224
- ),
225
- play: async ({ canvasElement }) => {
226
- await typeAndCreate(canvasElement, "Taxes");
227
- },
228
- };
229
-
230
- /**
231
- * Retry is a resume: the first create times out, and pressing the create row
232
- * again with the same name resolves and moves — the hook re-waits on the folder
233
- * it already made rather than re-creating it.
234
- */
235
- export const CreateFolderRetrySucceeds: Story = {
236
- name: "Create folder — retry resumes and moves",
237
- render: () => {
238
- const RetryStage = () => {
239
- const [moved, setMoved] = useState<string | null>(null);
240
- return (
241
- <div className="flex flex-col">
242
- <MoveMailboxPicker
243
- mailboxes={mailboxes}
244
- onSelect={setMoved}
245
- onCreateFolder={failThenSucceedCreateFolder()}
246
- />
247
- {moved && (
248
- <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
249
- Moved to {moved}
250
- </p>
251
- )}
252
- </div>
253
- );
254
- };
255
- return <RetryStage />;
256
- },
257
- play: async ({ canvasElement }) => {
258
- await typeAndCreate(canvasElement, "Taxes");
259
- await tick();
260
- const retry = Array.from(
261
- canvasElement.querySelectorAll<HTMLButtonElement>("button"),
262
- ).find((button) => button.textContent?.includes('Create "Taxes"'));
263
- retry?.click();
264
- },
265
- };
266
-
267
- /**
268
- * Closing the picker while "Creating folder…" is in flight aborts the wait: the
269
- * create promise rejects with an AbortError, so a folder that would confirm later
270
- * never fires the move after the picker is gone. Here "Close picker" unmounts it
271
- * mid-wait; no "Moved to" line appears.
272
- */
273
- export const CreateFolderClosedMidWait: Story = {
274
- name: "Create folder — closing aborts the move",
275
- render: () => {
276
- const AbortStage = () => {
277
- const [open, setOpen] = useState(true);
278
- const [moved, setMoved] = useState<string | null>(null);
279
- return (
280
- <div className="flex flex-col">
281
- <button
282
- type="button"
283
- onClick={() => setOpen(false)}
284
- className="border-b border-line px-3 py-2 text-left text-xs text-fg-muted"
285
- >
286
- Close picker
287
- </button>
288
- {open && (
289
- <MoveMailboxPicker
290
- mailboxes={mailboxes}
291
- onSelect={setMoved}
292
- onCreateFolder={abortAwareCreateFolder}
293
- />
294
- )}
295
- {moved && (
296
- <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
297
- Moved to {moved}
298
- </p>
299
- )}
300
- </div>
301
- );
302
- };
303
- return <AbortStage />;
304
- },
305
- play: async ({ canvasElement }) => {
306
- await typeAndCreate(canvasElement, "Taxes");
307
- await tick();
308
- const close = Array.from(
309
- canvasElement.querySelectorAll<HTMLButtonElement>("button"),
310
- ).find((button) => button.textContent?.trim() === "Close picker");
311
- close?.click();
312
- },
313
- };
@@ -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
- };