@remit/ui 0.0.65 → 0.0.67
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,893 @@
|
|
|
1
|
+
import { Check, ChevronRight, Folder, FolderPlus, Search } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { isAbortError } from "../lib/abort.js";
|
|
12
|
+
import { cn } from "../lib/cn.js";
|
|
13
|
+
import { Button } from "./button.js";
|
|
14
|
+
import { FieldLabel } from "./field-label.js";
|
|
15
|
+
import { Input } from "./input.js";
|
|
16
|
+
|
|
17
|
+
export interface FolderTreeNode {
|
|
18
|
+
/** Stable identity passed back to `onSelect`. */
|
|
19
|
+
id: string;
|
|
20
|
+
/**
|
|
21
|
+
* What the row reads as. An appointed folder is labelled by its role, so
|
|
22
|
+
* `Deleted Messages` shows as "Trash" while still nesting under its real
|
|
23
|
+
* path — which is why the label is not derived from the path.
|
|
24
|
+
*/
|
|
25
|
+
label: string;
|
|
26
|
+
/** The provider path. Nesting, indentation and filtering all read this. */
|
|
27
|
+
path: string;
|
|
28
|
+
/**
|
|
29
|
+
* Where the messages live now. A "you are here" marker: never a target, and
|
|
30
|
+
* rendered as a marker rather than a disabled control.
|
|
31
|
+
*/
|
|
32
|
+
isCurrent?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FolderTreePickerLabels {
|
|
36
|
+
filterPlaceholder?: string;
|
|
37
|
+
filterAriaLabel?: string;
|
|
38
|
+
treeAriaLabel?: string;
|
|
39
|
+
/** Suffix announced for the current folder, e.g. `(current folder)`. */
|
|
40
|
+
currentSuffix?: string;
|
|
41
|
+
/** Inline tag shown on the current folder row. */
|
|
42
|
+
currentTag?: string;
|
|
43
|
+
/** Suffix announced for an ancestor held on screen by a match below it. */
|
|
44
|
+
contextSuffix?: string;
|
|
45
|
+
emptyMessage?: (query: string) => string;
|
|
46
|
+
/** Accessible label for a selectable row, e.g. `Move to X`. */
|
|
47
|
+
optionLabel?: (label: string) => string;
|
|
48
|
+
newFolder?: string;
|
|
49
|
+
newSubfolder?: (label: string) => string;
|
|
50
|
+
nameLabel?: string;
|
|
51
|
+
namePlaceholder?: string;
|
|
52
|
+
insideLabel?: string;
|
|
53
|
+
topLevel?: string;
|
|
54
|
+
create?: string;
|
|
55
|
+
cancel?: string;
|
|
56
|
+
nameRequired?: string;
|
|
57
|
+
createPending?: string;
|
|
58
|
+
createError?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FolderTreePickerProps {
|
|
62
|
+
/** Destinations as the app has them — labelled, and pathed by the provider. */
|
|
63
|
+
folders: readonly FolderTreeNode[];
|
|
64
|
+
/** The destination chosen so far. */
|
|
65
|
+
selectedId?: string;
|
|
66
|
+
/** Marks the row. Choosing a destination advances nothing on its own. */
|
|
67
|
+
onSelect: (folderId: string) => void;
|
|
68
|
+
/**
|
|
69
|
+
* Creating a folder is an IMAP mutation, so this resolves only once the mail
|
|
70
|
+
* server confirms the folder (docs/architecture/imap-mutations.md). The form
|
|
71
|
+
* holds the wait, refuses a second submit while it runs, states a failure
|
|
72
|
+
* where it happened, and aborts the signal on unmount so a late confirmation
|
|
73
|
+
* never selects a folder into a surface that is gone. Absent means no create
|
|
74
|
+
* affordance renders.
|
|
75
|
+
*/
|
|
76
|
+
onCreateFolder?: (
|
|
77
|
+
name: string,
|
|
78
|
+
parentPath: string,
|
|
79
|
+
signal?: AbortSignal,
|
|
80
|
+
) => Promise<FolderTreeNode>;
|
|
81
|
+
/** Escape. The picker never owns its presentation, so it cannot close itself. */
|
|
82
|
+
onCancel?: () => void;
|
|
83
|
+
/** The provider's hierarchy separator. */
|
|
84
|
+
delimiter?: string;
|
|
85
|
+
labels?: FolderTreePickerLabels;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const defaultLabels: Required<FolderTreePickerLabels> = {
|
|
89
|
+
filterPlaceholder: "Filter folders…",
|
|
90
|
+
filterAriaLabel: "Filter folders",
|
|
91
|
+
treeAriaLabel: "Destination folders",
|
|
92
|
+
currentSuffix: "(current folder)",
|
|
93
|
+
currentTag: "current",
|
|
94
|
+
contextSuffix: "(containing folder)",
|
|
95
|
+
emptyMessage: (query) => `No folders match "${query}"`,
|
|
96
|
+
optionLabel: (label) => `Move to ${label}`,
|
|
97
|
+
newFolder: "New folder",
|
|
98
|
+
newSubfolder: (label) => `New folder inside ${label}`,
|
|
99
|
+
nameLabel: "Folder name",
|
|
100
|
+
namePlaceholder: "Hotels",
|
|
101
|
+
insideLabel: "Inside",
|
|
102
|
+
topLevel: "Top level",
|
|
103
|
+
create: "Create folder",
|
|
104
|
+
cancel: "Cancel",
|
|
105
|
+
nameRequired: "Give the folder a name.",
|
|
106
|
+
createPending: "Creating folder…",
|
|
107
|
+
createError: "Couldn't create that folder. Please try again.",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const ROW_BASE =
|
|
111
|
+
"flex min-h-11 min-w-0 flex-1 items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset";
|
|
112
|
+
|
|
113
|
+
const INDENT_STEP = 14;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Where a row's text starts: `px-3` plus the chevron and folder icon columns
|
|
117
|
+
* with their gaps. The hairline separator is inset to it, so the line begins
|
|
118
|
+
* under the label the way a native mobile list draws it.
|
|
119
|
+
*/
|
|
120
|
+
const ROW_TEXT_INSET = 60;
|
|
121
|
+
|
|
122
|
+
const folderParent = (path: string, delimiter: string): string => {
|
|
123
|
+
const cut = path.lastIndexOf(delimiter);
|
|
124
|
+
return cut === -1 ? "" : path.slice(0, cut);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const folderDepth = (path: string, delimiter: string): number =>
|
|
128
|
+
path.split(delimiter).length - 1;
|
|
129
|
+
|
|
130
|
+
const folderAncestors = (path: string, delimiter: string): string[] => {
|
|
131
|
+
const out: string[] = [];
|
|
132
|
+
let parent = folderParent(path, delimiter);
|
|
133
|
+
while (parent) {
|
|
134
|
+
out.push(parent);
|
|
135
|
+
parent = folderParent(parent, delimiter);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Puts every child straight after its parent so the list reads as a tree, while
|
|
142
|
+
* leaving the order of unrelated folders alone. A folder whose parent is absent
|
|
143
|
+
* from the list renders as a root rather than disappearing.
|
|
144
|
+
*/
|
|
145
|
+
const orderFolderNodes = (
|
|
146
|
+
folders: readonly FolderTreeNode[],
|
|
147
|
+
delimiter: string,
|
|
148
|
+
): FolderTreeNode[] => {
|
|
149
|
+
const present = new Set(folders.map((folder) => folder.path));
|
|
150
|
+
const emitted = new Set<string>();
|
|
151
|
+
const out: FolderTreeNode[] = [];
|
|
152
|
+
|
|
153
|
+
const emit = (folder: FolderTreeNode) => {
|
|
154
|
+
if (emitted.has(folder.path)) return;
|
|
155
|
+
emitted.add(folder.path);
|
|
156
|
+
out.push(folder);
|
|
157
|
+
for (const candidate of folders) {
|
|
158
|
+
if (folderParent(candidate.path, delimiter) === folder.path) {
|
|
159
|
+
emit(candidate);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
for (const folder of folders) {
|
|
165
|
+
const parent = folderParent(folder.path, delimiter);
|
|
166
|
+
if (parent && present.has(parent)) continue;
|
|
167
|
+
emit(folder);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const matchesQuery = (folder: FolderTreeNode, query: string): boolean =>
|
|
173
|
+
folder.label.toLowerCase().includes(query) ||
|
|
174
|
+
folder.path.toLowerCase().includes(query);
|
|
175
|
+
|
|
176
|
+
export interface FolderTreeRow {
|
|
177
|
+
folder: FolderTreeNode;
|
|
178
|
+
depth: number;
|
|
179
|
+
/**
|
|
180
|
+
* On screen only to keep a match below it in place. It reads as the branch
|
|
181
|
+
* it is, not as an answer to what was typed.
|
|
182
|
+
*/
|
|
183
|
+
context: boolean;
|
|
184
|
+
/** Its children and its create action are on screen. */
|
|
185
|
+
expanded: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The ancestors a query has to open for its matches to be on screen. Held apart
|
|
190
|
+
* from what the user opened by hand, so clearing the filter puts the list back
|
|
191
|
+
* the way they left it.
|
|
192
|
+
*/
|
|
193
|
+
const queryExpandedPaths = (
|
|
194
|
+
folders: readonly FolderTreeNode[],
|
|
195
|
+
query: string,
|
|
196
|
+
delimiter: string,
|
|
197
|
+
): Set<string> => {
|
|
198
|
+
const out = new Set<string>();
|
|
199
|
+
if (!query) return out;
|
|
200
|
+
for (const folder of folders) {
|
|
201
|
+
if (!matchesQuery(folder, query)) continue;
|
|
202
|
+
for (const ancestor of folderAncestors(folder.path, delimiter)) {
|
|
203
|
+
out.add(ancestor);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The rows a filtered tree shows: every match, plus the ancestors holding it on
|
|
211
|
+
* screen. Depth still comes from the path, so a match stays indented under the
|
|
212
|
+
* branch it belongs to.
|
|
213
|
+
*/
|
|
214
|
+
const filterFolderTree = (
|
|
215
|
+
ordered: readonly FolderTreeNode[],
|
|
216
|
+
query: string,
|
|
217
|
+
delimiter: string,
|
|
218
|
+
expanded: ReadonlySet<string> = new Set(),
|
|
219
|
+
): FolderTreeRow[] => {
|
|
220
|
+
const row = (folder: FolderTreeNode, context: boolean): FolderTreeRow => ({
|
|
221
|
+
folder,
|
|
222
|
+
depth: folderDepth(folder.path, delimiter),
|
|
223
|
+
context,
|
|
224
|
+
expanded: expanded.has(folder.path),
|
|
225
|
+
});
|
|
226
|
+
if (!query) return ordered.map((folder) => row(folder, false));
|
|
227
|
+
|
|
228
|
+
const matched = new Set<string>();
|
|
229
|
+
for (const folder of ordered) {
|
|
230
|
+
if (matchesQuery(folder, query)) matched.add(folder.path);
|
|
231
|
+
}
|
|
232
|
+
const visible = new Set(matched);
|
|
233
|
+
for (const path of matched) {
|
|
234
|
+
for (const ancestor of folderAncestors(path, delimiter)) {
|
|
235
|
+
visible.add(ancestor);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return ordered
|
|
239
|
+
.filter((folder) => visible.has(folder.path))
|
|
240
|
+
.map((folder) => row(folder, !matched.has(folder.path)));
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The unfiltered list: roots always, and a child only while every ancestor it
|
|
245
|
+
* has on screen is open. A folder whose parent is absent from the list is a
|
|
246
|
+
* root, so it never hides behind something that was never there.
|
|
247
|
+
*/
|
|
248
|
+
const collapseFolderTree = (
|
|
249
|
+
ordered: readonly FolderTreeNode[],
|
|
250
|
+
expanded: ReadonlySet<string>,
|
|
251
|
+
delimiter: string,
|
|
252
|
+
): FolderTreeRow[] => {
|
|
253
|
+
const present = new Set(ordered.map((folder) => folder.path));
|
|
254
|
+
return ordered
|
|
255
|
+
.filter((folder) =>
|
|
256
|
+
folderAncestors(folder.path, delimiter).every(
|
|
257
|
+
(ancestor) => !present.has(ancestor) || expanded.has(ancestor),
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
.map((folder) => ({
|
|
261
|
+
folder,
|
|
262
|
+
depth: folderDepth(folder.path, delimiter),
|
|
263
|
+
context: false,
|
|
264
|
+
expanded: expanded.has(folder.path),
|
|
265
|
+
}));
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
export type FolderTreeDisplayRow =
|
|
269
|
+
| { kind: "folder"; row: FolderTreeRow; index: number }
|
|
270
|
+
| { kind: "create"; parent: FolderTreeNode; depth: number };
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Drops a create action at the end of every open folder's children, so "New
|
|
274
|
+
* folder" reads as the last folder inside the one you opened.
|
|
275
|
+
*/
|
|
276
|
+
const withCreateRows = (
|
|
277
|
+
rows: readonly FolderTreeRow[],
|
|
278
|
+
delimiter: string,
|
|
279
|
+
): FolderTreeDisplayRow[] => {
|
|
280
|
+
const out: FolderTreeDisplayRow[] = [];
|
|
281
|
+
const open: FolderTreeRow[] = [];
|
|
282
|
+
|
|
283
|
+
const closeDownTo = (path: string | null) => {
|
|
284
|
+
while (open.length > 0) {
|
|
285
|
+
const last = open[open.length - 1];
|
|
286
|
+
if (!last) break;
|
|
287
|
+
if (path?.startsWith(`${last.folder.path}${delimiter}`)) break;
|
|
288
|
+
open.pop();
|
|
289
|
+
out.push({
|
|
290
|
+
kind: "create",
|
|
291
|
+
parent: last.folder,
|
|
292
|
+
depth: last.depth + 1,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
rows.forEach((row, index) => {
|
|
298
|
+
closeDownTo(row.folder.path);
|
|
299
|
+
out.push({ kind: "folder", row, index });
|
|
300
|
+
if (row.expanded) open.push(row);
|
|
301
|
+
});
|
|
302
|
+
closeDownTo(null);
|
|
303
|
+
return out;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/** Every folder can hold a new one, so every row but a context row can open. */
|
|
307
|
+
const isFocusable = (row: FolderTreeRow | undefined): boolean =>
|
|
308
|
+
row !== undefined && !row.context;
|
|
309
|
+
|
|
310
|
+
const isSelectable = (row: FolderTreeRow | undefined): boolean =>
|
|
311
|
+
row !== undefined && !row.folder.isCurrent && !row.context;
|
|
312
|
+
|
|
313
|
+
const findFirstFocusable = (rows: readonly FolderTreeRow[]): number => {
|
|
314
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
315
|
+
if (isFocusable(rows[i])) return i;
|
|
316
|
+
}
|
|
317
|
+
return -1;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const findLastFocusable = (rows: readonly FolderTreeRow[]): number => {
|
|
321
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
322
|
+
if (isFocusable(rows[i])) return i;
|
|
323
|
+
}
|
|
324
|
+
return -1;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const findNextFocusable = (
|
|
328
|
+
rows: readonly FolderTreeRow[],
|
|
329
|
+
from: number,
|
|
330
|
+
step: 1 | -1,
|
|
331
|
+
): number => {
|
|
332
|
+
const count = rows.length;
|
|
333
|
+
if (count <= 0) return -1;
|
|
334
|
+
const start = from < 0 ? (step === 1 ? -1 : count) : from;
|
|
335
|
+
for (let offset = 1; offset <= count; offset += 1) {
|
|
336
|
+
const candidate = (((start + step * offset) % count) + count) % count;
|
|
337
|
+
if (isFocusable(rows[candidate])) return candidate;
|
|
338
|
+
}
|
|
339
|
+
return -1;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const findParentRow = (
|
|
343
|
+
rows: readonly FolderTreeRow[],
|
|
344
|
+
from: number,
|
|
345
|
+
delimiter: string,
|
|
346
|
+
): number => {
|
|
347
|
+
const child = rows[from];
|
|
348
|
+
if (!child) return -1;
|
|
349
|
+
const parent = folderParent(child.folder.path, delimiter);
|
|
350
|
+
if (!parent) return -1;
|
|
351
|
+
for (let i = from - 1; i >= 0; i -= 1) {
|
|
352
|
+
if (rows[i]?.folder.path === parent) return isFocusable(rows[i]) ? i : -1;
|
|
353
|
+
}
|
|
354
|
+
return -1;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
interface Draft {
|
|
358
|
+
/** The row the form was opened from; `null` is the top-level row. */
|
|
359
|
+
anchorId: string | null;
|
|
360
|
+
parentPath: string;
|
|
361
|
+
parentLabel: string;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const NewFolderAction = ({
|
|
365
|
+
label,
|
|
366
|
+
ariaLabel,
|
|
367
|
+
depth,
|
|
368
|
+
separated,
|
|
369
|
+
onOpen,
|
|
370
|
+
}: {
|
|
371
|
+
label: string;
|
|
372
|
+
ariaLabel: string;
|
|
373
|
+
depth: number;
|
|
374
|
+
separated: boolean;
|
|
375
|
+
onOpen: () => void;
|
|
376
|
+
}) => (
|
|
377
|
+
<div className="relative">
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
onClick={onOpen}
|
|
381
|
+
aria-label={ariaLabel}
|
|
382
|
+
className={cn(
|
|
383
|
+
ROW_BASE,
|
|
384
|
+
"group relative w-full font-medium text-accent-2",
|
|
385
|
+
)}
|
|
386
|
+
>
|
|
387
|
+
{/* The tint starts at the row's indent, so nested actions read as a
|
|
388
|
+
staircase instead of merging into one block. */}
|
|
389
|
+
<span
|
|
390
|
+
aria-hidden="true"
|
|
391
|
+
className="absolute inset-y-0 right-0 bg-accent-2-soft transition-colors group-active:bg-accent-2/25"
|
|
392
|
+
style={{ left: depth * INDENT_STEP }}
|
|
393
|
+
/>
|
|
394
|
+
{depth > 0 && (
|
|
395
|
+
<span
|
|
396
|
+
aria-hidden="true"
|
|
397
|
+
className="relative shrink-0"
|
|
398
|
+
style={{ width: depth * INDENT_STEP }}
|
|
399
|
+
/>
|
|
400
|
+
)}
|
|
401
|
+
<span aria-hidden="true" className="relative size-4 shrink-0" />
|
|
402
|
+
<FolderPlus className="relative size-4 shrink-0" aria-hidden="true" />
|
|
403
|
+
<span className="relative min-w-0 flex-1 truncate">{label}</span>
|
|
404
|
+
</button>
|
|
405
|
+
{separated && (
|
|
406
|
+
<span
|
|
407
|
+
aria-hidden="true"
|
|
408
|
+
className="pointer-events-none absolute right-0 bottom-0 h-px bg-line"
|
|
409
|
+
style={{ left: ROW_TEXT_INSET + depth * INDENT_STEP }}
|
|
410
|
+
/>
|
|
411
|
+
)}
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Browsable destination picker: the folders as a tree that starts at its top
|
|
417
|
+
* level, opens a folder where you tap it, and makes a new folder wherever you
|
|
418
|
+
* are looking. Data stays app-shaped — the kit owns ordering, filtering, focus
|
|
419
|
+
* and the create wait; the app owns labels, paths and the move itself.
|
|
420
|
+
*/
|
|
421
|
+
export const FolderTreePicker = ({
|
|
422
|
+
folders,
|
|
423
|
+
selectedId,
|
|
424
|
+
onSelect,
|
|
425
|
+
onCreateFolder,
|
|
426
|
+
onCancel,
|
|
427
|
+
delimiter = "/",
|
|
428
|
+
labels,
|
|
429
|
+
}: FolderTreePickerProps) => {
|
|
430
|
+
const text = { ...defaultLabels, ...labels };
|
|
431
|
+
const [query, setQuery] = useState("");
|
|
432
|
+
const [opened, setOpened] = useState<ReadonlySet<string>>(new Set());
|
|
433
|
+
const [draft, setDraft] = useState<Draft | null>(null);
|
|
434
|
+
const [draftName, setDraftName] = useState("");
|
|
435
|
+
const [draftError, setDraftError] = useState<string>();
|
|
436
|
+
const [creating, setCreating] = useState(false);
|
|
437
|
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
438
|
+
|
|
439
|
+
const nameFieldId = useId();
|
|
440
|
+
const rowRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
441
|
+
const nameRef = useRef<HTMLInputElement>(null);
|
|
442
|
+
const roving = useRef(false);
|
|
443
|
+
const createAbort = useRef<AbortController | null>(null);
|
|
444
|
+
useEffect(() => () => createAbort.current?.abort(), []);
|
|
445
|
+
|
|
446
|
+
const trimmedQuery = query.trim().toLowerCase();
|
|
447
|
+
const ordered = useMemo(
|
|
448
|
+
() => orderFolderNodes(folders, delimiter),
|
|
449
|
+
[folders, delimiter],
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const expanded = useMemo(() => {
|
|
453
|
+
const auto = queryExpandedPaths(ordered, trimmedQuery, delimiter);
|
|
454
|
+
if (auto.size === 0) return opened;
|
|
455
|
+
return new Set([...opened, ...auto]);
|
|
456
|
+
}, [ordered, opened, trimmedQuery, delimiter]);
|
|
457
|
+
|
|
458
|
+
const rows = useMemo(
|
|
459
|
+
() =>
|
|
460
|
+
trimmedQuery
|
|
461
|
+
? filterFolderTree(ordered, trimmedQuery, delimiter, expanded)
|
|
462
|
+
: collapseFolderTree(ordered, expanded, delimiter),
|
|
463
|
+
[ordered, trimmedQuery, delimiter, expanded],
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const displayRows = useMemo(
|
|
467
|
+
() => (onCreateFolder ? withCreateRows(rows, delimiter) : undefined),
|
|
468
|
+
[rows, delimiter, onCreateFolder],
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
setFocusedIndex((current) =>
|
|
473
|
+
isFocusable(rows[current]) ? current : findFirstFocusable(rows),
|
|
474
|
+
);
|
|
475
|
+
}, [rows]);
|
|
476
|
+
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
if (!roving.current) return;
|
|
479
|
+
roving.current = false;
|
|
480
|
+
if (focusedIndex < 0) return;
|
|
481
|
+
rowRefs.current[focusedIndex]?.focus();
|
|
482
|
+
}, [focusedIndex]);
|
|
483
|
+
|
|
484
|
+
const setExpanded = useCallback((path: string, open: boolean) => {
|
|
485
|
+
setOpened((current) => {
|
|
486
|
+
const next = new Set(current);
|
|
487
|
+
if (open) next.add(path);
|
|
488
|
+
else next.delete(path);
|
|
489
|
+
return next;
|
|
490
|
+
});
|
|
491
|
+
}, []);
|
|
492
|
+
|
|
493
|
+
const activateRow = useCallback(
|
|
494
|
+
(row: FolderTreeRow) => {
|
|
495
|
+
if (isSelectable(row)) onSelect(row.folder.id);
|
|
496
|
+
setExpanded(row.folder.path, !row.expanded);
|
|
497
|
+
},
|
|
498
|
+
[onSelect, setExpanded],
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
const closeDraft = useCallback(() => {
|
|
502
|
+
createAbort.current?.abort();
|
|
503
|
+
setDraft(null);
|
|
504
|
+
setDraftName("");
|
|
505
|
+
setDraftError(undefined);
|
|
506
|
+
setCreating(false);
|
|
507
|
+
}, []);
|
|
508
|
+
|
|
509
|
+
const openDraft = useCallback(
|
|
510
|
+
(anchor: FolderTreeNode | null) => {
|
|
511
|
+
createAbort.current?.abort();
|
|
512
|
+
setDraft({
|
|
513
|
+
anchorId: anchor?.id ?? null,
|
|
514
|
+
parentPath: anchor?.path ?? "",
|
|
515
|
+
parentLabel: anchor?.label ?? text.topLevel,
|
|
516
|
+
});
|
|
517
|
+
setDraftName("");
|
|
518
|
+
setDraftError(undefined);
|
|
519
|
+
setCreating(false);
|
|
520
|
+
},
|
|
521
|
+
[text.topLevel],
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
useEffect(() => {
|
|
525
|
+
if (draft) nameRef.current?.focus();
|
|
526
|
+
}, [draft]);
|
|
527
|
+
|
|
528
|
+
const submitDraft = useCallback(() => {
|
|
529
|
+
if (!onCreateFolder || !draft || creating) return;
|
|
530
|
+
const name = draftName.trim();
|
|
531
|
+
if (name === "") {
|
|
532
|
+
setDraftError(text.nameRequired);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
setCreating(true);
|
|
536
|
+
setDraftError(undefined);
|
|
537
|
+
createAbort.current?.abort();
|
|
538
|
+
const controller = new AbortController();
|
|
539
|
+
createAbort.current = controller;
|
|
540
|
+
const parentPath = draft.parentPath;
|
|
541
|
+
onCreateFolder(name, parentPath, controller.signal)
|
|
542
|
+
.then((created) => {
|
|
543
|
+
setCreating(false);
|
|
544
|
+
setDraft(null);
|
|
545
|
+
setDraftName("");
|
|
546
|
+
if (parentPath) setExpanded(parentPath, true);
|
|
547
|
+
onSelect(created.id);
|
|
548
|
+
})
|
|
549
|
+
.catch((error: unknown) => {
|
|
550
|
+
if (isAbortError(error)) return;
|
|
551
|
+
setDraftError(
|
|
552
|
+
error instanceof Error ? error.message : text.createError,
|
|
553
|
+
);
|
|
554
|
+
setCreating(false);
|
|
555
|
+
});
|
|
556
|
+
}, [
|
|
557
|
+
onCreateFolder,
|
|
558
|
+
draft,
|
|
559
|
+
creating,
|
|
560
|
+
draftName,
|
|
561
|
+
onSelect,
|
|
562
|
+
setExpanded,
|
|
563
|
+
text.nameRequired,
|
|
564
|
+
text.createError,
|
|
565
|
+
]);
|
|
566
|
+
|
|
567
|
+
const handleTreeKeyDown = useCallback(
|
|
568
|
+
(event: ReactKeyboardEvent<HTMLElement>) => {
|
|
569
|
+
const move = (next: number) => {
|
|
570
|
+
event.preventDefault();
|
|
571
|
+
roving.current = true;
|
|
572
|
+
setFocusedIndex(next);
|
|
573
|
+
};
|
|
574
|
+
const focused = rows[focusedIndex];
|
|
575
|
+
switch (event.key) {
|
|
576
|
+
case "ArrowDown":
|
|
577
|
+
return move(findNextFocusable(rows, focusedIndex, 1));
|
|
578
|
+
case "ArrowUp":
|
|
579
|
+
return move(findNextFocusable(rows, focusedIndex, -1));
|
|
580
|
+
case "Home":
|
|
581
|
+
return move(findFirstFocusable(rows));
|
|
582
|
+
case "End":
|
|
583
|
+
return move(findLastFocusable(rows));
|
|
584
|
+
case "ArrowRight": {
|
|
585
|
+
if (!isFocusable(focused) || !focused) return;
|
|
586
|
+
event.preventDefault();
|
|
587
|
+
if (!focused.expanded) {
|
|
588
|
+
setExpanded(focused.folder.path, true);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const next = focusedIndex + 1;
|
|
592
|
+
if (isFocusable(rows[next])) move(next);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
case "ArrowLeft": {
|
|
596
|
+
if (!isFocusable(focused) || !focused) return;
|
|
597
|
+
event.preventDefault();
|
|
598
|
+
if (focused.expanded) {
|
|
599
|
+
setExpanded(focused.folder.path, false);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const parent = findParentRow(rows, focusedIndex, delimiter);
|
|
603
|
+
if (parent >= 0) move(parent);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
case "Enter":
|
|
607
|
+
case " ": {
|
|
608
|
+
if (!isFocusable(focused) || !focused) return;
|
|
609
|
+
event.preventDefault();
|
|
610
|
+
activateRow(focused);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
case "Escape":
|
|
614
|
+
event.preventDefault();
|
|
615
|
+
onCancel?.();
|
|
616
|
+
return;
|
|
617
|
+
default:
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
[rows, focusedIndex, delimiter, activateRow, setExpanded, onCancel],
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
const draftForm = draft && (
|
|
625
|
+
<div className="space-y-3 border-y border-line bg-surface-sunken px-3 py-3">
|
|
626
|
+
<div>
|
|
627
|
+
<FieldLabel htmlFor={nameFieldId}>{text.nameLabel}</FieldLabel>
|
|
628
|
+
<Input
|
|
629
|
+
id={nameFieldId}
|
|
630
|
+
ref={nameRef}
|
|
631
|
+
value={draftName}
|
|
632
|
+
placeholder={text.namePlaceholder}
|
|
633
|
+
onChange={(event) => {
|
|
634
|
+
setDraftName(event.target.value);
|
|
635
|
+
setDraftError(undefined);
|
|
636
|
+
}}
|
|
637
|
+
onKeyDown={(event) => {
|
|
638
|
+
if (event.key === "Enter") {
|
|
639
|
+
event.preventDefault();
|
|
640
|
+
submitDraft();
|
|
641
|
+
}
|
|
642
|
+
if (event.key === "Escape") {
|
|
643
|
+
event.preventDefault();
|
|
644
|
+
closeDraft();
|
|
645
|
+
}
|
|
646
|
+
}}
|
|
647
|
+
/>
|
|
648
|
+
</div>
|
|
649
|
+
<p className="text-xs text-fg-muted">
|
|
650
|
+
{text.insideLabel}{" "}
|
|
651
|
+
<span className="font-medium text-fg">{draft.parentLabel}</span>
|
|
652
|
+
</p>
|
|
653
|
+
{draftError && (
|
|
654
|
+
<p className="text-xs text-danger" role="alert">
|
|
655
|
+
{draftError}
|
|
656
|
+
</p>
|
|
657
|
+
)}
|
|
658
|
+
<div className="flex items-center gap-2">
|
|
659
|
+
<Button
|
|
660
|
+
variant="ghost"
|
|
661
|
+
size="touch"
|
|
662
|
+
onClick={closeDraft}
|
|
663
|
+
className="w-auto shrink-0 px-3"
|
|
664
|
+
>
|
|
665
|
+
{text.cancel}
|
|
666
|
+
</Button>
|
|
667
|
+
<Button
|
|
668
|
+
variant="primary"
|
|
669
|
+
size="touch"
|
|
670
|
+
onClick={submitDraft}
|
|
671
|
+
disabled={creating}
|
|
672
|
+
className="w-auto flex-1 px-3"
|
|
673
|
+
>
|
|
674
|
+
{creating ? text.createPending : text.create}
|
|
675
|
+
</Button>
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
const renderFolderRow = (
|
|
681
|
+
row: FolderTreeRow,
|
|
682
|
+
index: number,
|
|
683
|
+
separated: boolean,
|
|
684
|
+
) => {
|
|
685
|
+
const { folder, depth } = row;
|
|
686
|
+
const selectable = isSelectable(row);
|
|
687
|
+
const focusable = isFocusable(row);
|
|
688
|
+
const indent = depth > 0 && (
|
|
689
|
+
<span
|
|
690
|
+
aria-hidden="true"
|
|
691
|
+
className="shrink-0"
|
|
692
|
+
style={{ width: depth * INDENT_STEP }}
|
|
693
|
+
/>
|
|
694
|
+
);
|
|
695
|
+
const chevron = (
|
|
696
|
+
<ChevronRight
|
|
697
|
+
className={cn(
|
|
698
|
+
"size-4 shrink-0 text-fg-subtle transition-transform",
|
|
699
|
+
row.expanded && "rotate-90",
|
|
700
|
+
)}
|
|
701
|
+
aria-hidden="true"
|
|
702
|
+
/>
|
|
703
|
+
);
|
|
704
|
+
const icon = (
|
|
705
|
+
<Folder className="size-4 shrink-0 text-fg-subtle" aria-hidden="true" />
|
|
706
|
+
);
|
|
707
|
+
const separator = separated && (
|
|
708
|
+
<span
|
|
709
|
+
aria-hidden="true"
|
|
710
|
+
className="pointer-events-none absolute right-0 bottom-0 h-px bg-line"
|
|
711
|
+
style={{ left: ROW_TEXT_INSET + depth * INDENT_STEP }}
|
|
712
|
+
/>
|
|
713
|
+
);
|
|
714
|
+
if (!focusable) {
|
|
715
|
+
return (
|
|
716
|
+
<div className="relative flex items-center">
|
|
717
|
+
{/* biome-ignore lint/a11y/useFocusableInteractive: an ancestor held on screen by a match below it — a branch, not a destination */}
|
|
718
|
+
<div
|
|
719
|
+
role="treeitem"
|
|
720
|
+
aria-level={depth + 1}
|
|
721
|
+
aria-selected={false}
|
|
722
|
+
aria-expanded={row.expanded}
|
|
723
|
+
aria-label={`${folder.label} ${text.contextSuffix}`}
|
|
724
|
+
className={cn(ROW_BASE, "opacity-60")}
|
|
725
|
+
>
|
|
726
|
+
{indent}
|
|
727
|
+
{chevron}
|
|
728
|
+
{icon}
|
|
729
|
+
<span className="min-w-0 flex-1 truncate">{folder.label}</span>
|
|
730
|
+
</div>
|
|
731
|
+
{separator}
|
|
732
|
+
</div>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
return (
|
|
736
|
+
<div className="relative flex items-center">
|
|
737
|
+
<button
|
|
738
|
+
ref={(node) => {
|
|
739
|
+
rowRefs.current[index] = node;
|
|
740
|
+
}}
|
|
741
|
+
type="button"
|
|
742
|
+
role="treeitem"
|
|
743
|
+
aria-level={depth + 1}
|
|
744
|
+
aria-selected={selectable ? folder.id === selectedId : false}
|
|
745
|
+
aria-expanded={row.expanded}
|
|
746
|
+
aria-current={folder.isCurrent ? "true" : undefined}
|
|
747
|
+
aria-label={
|
|
748
|
+
selectable
|
|
749
|
+
? text.optionLabel(folder.label)
|
|
750
|
+
: `${folder.label} ${text.currentSuffix}`
|
|
751
|
+
}
|
|
752
|
+
tabIndex={index === focusedIndex ? 0 : -1}
|
|
753
|
+
onClick={() => activateRow(row)}
|
|
754
|
+
onFocus={() => setFocusedIndex(index)}
|
|
755
|
+
className={cn(
|
|
756
|
+
ROW_BASE,
|
|
757
|
+
"hover:bg-surface-raised active:bg-surface-sunken",
|
|
758
|
+
folder.isCurrent && "text-fg-muted",
|
|
759
|
+
)}
|
|
760
|
+
>
|
|
761
|
+
{indent}
|
|
762
|
+
{chevron}
|
|
763
|
+
{icon}
|
|
764
|
+
<span className="min-w-0 flex-1 truncate">{folder.label}</span>
|
|
765
|
+
{folder.isCurrent && (
|
|
766
|
+
<span className="shrink-0 text-xs text-fg-muted">
|
|
767
|
+
{text.currentTag}
|
|
768
|
+
</span>
|
|
769
|
+
)}
|
|
770
|
+
{selectable && folder.id === selectedId && (
|
|
771
|
+
<Check className="size-4 shrink-0 text-accent" aria-hidden="true" />
|
|
772
|
+
)}
|
|
773
|
+
</button>
|
|
774
|
+
{separator}
|
|
775
|
+
</div>
|
|
776
|
+
);
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
return (
|
|
780
|
+
<div className="flex min-h-0 w-full min-w-0 flex-col">
|
|
781
|
+
<Input
|
|
782
|
+
variant="inline"
|
|
783
|
+
className="border-b border-line px-3 py-2"
|
|
784
|
+
icon={<Search className="size-4" aria-hidden="true" />}
|
|
785
|
+
type="search"
|
|
786
|
+
value={query}
|
|
787
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
788
|
+
onKeyDown={(event) => {
|
|
789
|
+
if (event.key === "Escape") {
|
|
790
|
+
event.preventDefault();
|
|
791
|
+
onCancel?.();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (event.key !== "ArrowDown") return;
|
|
795
|
+
event.preventDefault();
|
|
796
|
+
const first = isFocusable(rows[focusedIndex])
|
|
797
|
+
? focusedIndex
|
|
798
|
+
: findFirstFocusable(rows);
|
|
799
|
+
if (first < 0) return;
|
|
800
|
+
roving.current = true;
|
|
801
|
+
setFocusedIndex(first);
|
|
802
|
+
rowRefs.current[first]?.focus();
|
|
803
|
+
}}
|
|
804
|
+
placeholder={text.filterPlaceholder}
|
|
805
|
+
aria-label={text.filterAriaLabel}
|
|
806
|
+
/>
|
|
807
|
+
|
|
808
|
+
{onCreateFolder && (
|
|
809
|
+
<div className="shrink-0 border-b border-line">
|
|
810
|
+
<NewFolderAction
|
|
811
|
+
label={text.newFolder}
|
|
812
|
+
ariaLabel={text.newFolder}
|
|
813
|
+
depth={0}
|
|
814
|
+
separated={false}
|
|
815
|
+
onOpen={() => openDraft(null)}
|
|
816
|
+
/>
|
|
817
|
+
{draft?.anchorId === null && draftForm}
|
|
818
|
+
</div>
|
|
819
|
+
)}
|
|
820
|
+
|
|
821
|
+
{rows.length === 0 ? (
|
|
822
|
+
<p className="px-3 py-3 text-sm text-fg-muted" aria-live="polite">
|
|
823
|
+
{text.emptyMessage(query)}
|
|
824
|
+
</p>
|
|
825
|
+
) : (
|
|
826
|
+
// A flattened tree: `aria-level` carries the nesting the indentation
|
|
827
|
+
// shows. The row wrapper is presentational and the button itself is
|
|
828
|
+
// the treeitem — a role on a wrapper around a separately-interactive
|
|
829
|
+
// button is invalid ARIA.
|
|
830
|
+
<div
|
|
831
|
+
role="tree"
|
|
832
|
+
aria-label={text.treeAriaLabel}
|
|
833
|
+
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden"
|
|
834
|
+
onKeyDown={handleTreeKeyDown}
|
|
835
|
+
>
|
|
836
|
+
{(
|
|
837
|
+
displayRows ??
|
|
838
|
+
rows.map(
|
|
839
|
+
(row, index): FolderTreeDisplayRow => ({
|
|
840
|
+
kind: "folder",
|
|
841
|
+
row,
|
|
842
|
+
index,
|
|
843
|
+
}),
|
|
844
|
+
)
|
|
845
|
+
).map((entry, position, all) => {
|
|
846
|
+
const separated = position < all.length - 1;
|
|
847
|
+
if (entry.kind === "create") {
|
|
848
|
+
return (
|
|
849
|
+
<div key={`new:${entry.parent.id}`} role="none">
|
|
850
|
+
<NewFolderAction
|
|
851
|
+
label={text.newFolder}
|
|
852
|
+
ariaLabel={text.newSubfolder(entry.parent.label)}
|
|
853
|
+
depth={entry.depth}
|
|
854
|
+
separated={separated}
|
|
855
|
+
onOpen={() => openDraft(entry.parent)}
|
|
856
|
+
/>
|
|
857
|
+
{draft?.anchorId === entry.parent.id && draftForm}
|
|
858
|
+
</div>
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
return (
|
|
862
|
+
<div key={entry.row.folder.id} role="none">
|
|
863
|
+
{renderFolderRow(entry.row, entry.index, separated)}
|
|
864
|
+
</div>
|
|
865
|
+
);
|
|
866
|
+
})}
|
|
867
|
+
</div>
|
|
868
|
+
)}
|
|
869
|
+
</div>
|
|
870
|
+
);
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Pure ordering, filtering, expansion and roving-focus helpers, exposed for unit
|
|
875
|
+
* testing without a DOM. Consumers should use {@link FolderTreePicker}.
|
|
876
|
+
*/
|
|
877
|
+
export const folderTreePickerInternals = {
|
|
878
|
+
folderParent,
|
|
879
|
+
folderDepth,
|
|
880
|
+
folderAncestors,
|
|
881
|
+
orderFolderNodes,
|
|
882
|
+
filterFolderTree,
|
|
883
|
+
collapseFolderTree,
|
|
884
|
+
queryExpandedPaths,
|
|
885
|
+
withCreateRows,
|
|
886
|
+
matchesQuery,
|
|
887
|
+
isFocusable,
|
|
888
|
+
isSelectable,
|
|
889
|
+
findFirstFocusable,
|
|
890
|
+
findLastFocusable,
|
|
891
|
+
findNextFocusable,
|
|
892
|
+
findParentRow,
|
|
893
|
+
};
|