@remit/ui 0.0.66 → 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.
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Check, Folder, FolderPlus, Search } from "lucide-react";
|
|
1
|
+
import { Check, ChevronRight, Folder, FolderPlus, Search } from "lucide-react";
|
|
2
2
|
import {
|
|
3
3
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
4
4
|
useCallback,
|
|
@@ -112,6 +112,13 @@ const ROW_BASE =
|
|
|
112
112
|
|
|
113
113
|
const INDENT_STEP = 14;
|
|
114
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
|
+
|
|
115
122
|
const folderParent = (path: string, delimiter: string): string => {
|
|
116
123
|
const cut = path.lastIndexOf(delimiter);
|
|
117
124
|
return cut === -1 ? "" : path.slice(0, cut);
|
|
@@ -120,6 +127,16 @@ const folderParent = (path: string, delimiter: string): string => {
|
|
|
120
127
|
const folderDepth = (path: string, delimiter: string): number =>
|
|
121
128
|
path.split(delimiter).length - 1;
|
|
122
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
|
+
|
|
123
140
|
/**
|
|
124
141
|
* Puts every child straight after its parent so the list reads as a tree, while
|
|
125
142
|
* leaving the order of unrelated folders alone. A folder whose parent is absent
|
|
@@ -164,17 +181,47 @@ export interface FolderTreeRow {
|
|
|
164
181
|
* it is, not as an answer to what was typed.
|
|
165
182
|
*/
|
|
166
183
|
context: boolean;
|
|
184
|
+
/** Its children and its create action are on screen. */
|
|
185
|
+
expanded: boolean;
|
|
167
186
|
}
|
|
168
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
|
+
*/
|
|
169
214
|
const filterFolderTree = (
|
|
170
215
|
ordered: readonly FolderTreeNode[],
|
|
171
216
|
query: string,
|
|
172
217
|
delimiter: string,
|
|
218
|
+
expanded: ReadonlySet<string> = new Set(),
|
|
173
219
|
): FolderTreeRow[] => {
|
|
174
220
|
const row = (folder: FolderTreeNode, context: boolean): FolderTreeRow => ({
|
|
175
221
|
folder,
|
|
176
222
|
depth: folderDepth(folder.path, delimiter),
|
|
177
223
|
context,
|
|
224
|
+
expanded: expanded.has(folder.path),
|
|
178
225
|
});
|
|
179
226
|
if (!query) return ordered.map((folder) => row(folder, false));
|
|
180
227
|
|
|
@@ -184,10 +231,8 @@ const filterFolderTree = (
|
|
|
184
231
|
}
|
|
185
232
|
const visible = new Set(matched);
|
|
186
233
|
for (const path of matched) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
visible.add(parent);
|
|
190
|
-
parent = folderParent(parent, delimiter);
|
|
234
|
+
for (const ancestor of folderAncestors(path, delimiter)) {
|
|
235
|
+
visible.add(ancestor);
|
|
191
236
|
}
|
|
192
237
|
}
|
|
193
238
|
return ordered
|
|
@@ -195,24 +240,91 @@ const filterFolderTree = (
|
|
|
195
240
|
.map((folder) => row(folder, !matched.has(folder.path)));
|
|
196
241
|
};
|
|
197
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
|
+
|
|
198
310
|
const isSelectable = (row: FolderTreeRow | undefined): boolean =>
|
|
199
311
|
row !== undefined && !row.folder.isCurrent && !row.context;
|
|
200
312
|
|
|
201
|
-
const
|
|
313
|
+
const findFirstFocusable = (rows: readonly FolderTreeRow[]): number => {
|
|
202
314
|
for (let i = 0; i < rows.length; i += 1) {
|
|
203
|
-
if (
|
|
315
|
+
if (isFocusable(rows[i])) return i;
|
|
204
316
|
}
|
|
205
317
|
return -1;
|
|
206
318
|
};
|
|
207
319
|
|
|
208
|
-
const
|
|
320
|
+
const findLastFocusable = (rows: readonly FolderTreeRow[]): number => {
|
|
209
321
|
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
210
|
-
if (
|
|
322
|
+
if (isFocusable(rows[i])) return i;
|
|
211
323
|
}
|
|
212
324
|
return -1;
|
|
213
325
|
};
|
|
214
326
|
|
|
215
|
-
const
|
|
327
|
+
const findNextFocusable = (
|
|
216
328
|
rows: readonly FolderTreeRow[],
|
|
217
329
|
from: number,
|
|
218
330
|
step: 1 | -1,
|
|
@@ -222,7 +334,22 @@ const findNextSelectable = (
|
|
|
222
334
|
const start = from < 0 ? (step === 1 ? -1 : count) : from;
|
|
223
335
|
for (let offset = 1; offset <= count; offset += 1) {
|
|
224
336
|
const candidate = (((start + step * offset) % count) + count) % count;
|
|
225
|
-
if (
|
|
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;
|
|
226
353
|
}
|
|
227
354
|
return -1;
|
|
228
355
|
};
|
|
@@ -234,11 +361,62 @@ interface Draft {
|
|
|
234
361
|
parentLabel: string;
|
|
235
362
|
}
|
|
236
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
|
+
|
|
237
415
|
/**
|
|
238
|
-
* Browsable destination picker: the folders as a tree
|
|
239
|
-
*
|
|
240
|
-
* stays app-shaped — the kit owns ordering, filtering, focus
|
|
241
|
-
* wait; the app owns labels, paths and the move itself.
|
|
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.
|
|
242
420
|
*/
|
|
243
421
|
export const FolderTreePicker = ({
|
|
244
422
|
folders,
|
|
@@ -251,6 +429,7 @@ export const FolderTreePicker = ({
|
|
|
251
429
|
}: FolderTreePickerProps) => {
|
|
252
430
|
const text = { ...defaultLabels, ...labels };
|
|
253
431
|
const [query, setQuery] = useState("");
|
|
432
|
+
const [opened, setOpened] = useState<ReadonlySet<string>>(new Set());
|
|
254
433
|
const [draft, setDraft] = useState<Draft | null>(null);
|
|
255
434
|
const [draftName, setDraftName] = useState("");
|
|
256
435
|
const [draftError, setDraftError] = useState<string>();
|
|
@@ -265,19 +444,33 @@ export const FolderTreePicker = ({
|
|
|
265
444
|
useEffect(() => () => createAbort.current?.abort(), []);
|
|
266
445
|
|
|
267
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
|
+
|
|
268
458
|
const rows = useMemo(
|
|
269
459
|
() =>
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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],
|
|
276
469
|
);
|
|
277
470
|
|
|
278
471
|
useEffect(() => {
|
|
279
472
|
setFocusedIndex((current) =>
|
|
280
|
-
|
|
473
|
+
isFocusable(rows[current]) ? current : findFirstFocusable(rows),
|
|
281
474
|
);
|
|
282
475
|
}, [rows]);
|
|
283
476
|
|
|
@@ -288,6 +481,23 @@ export const FolderTreePicker = ({
|
|
|
288
481
|
rowRefs.current[focusedIndex]?.focus();
|
|
289
482
|
}, [focusedIndex]);
|
|
290
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
|
+
|
|
291
501
|
const closeDraft = useCallback(() => {
|
|
292
502
|
createAbort.current?.abort();
|
|
293
503
|
setDraft(null);
|
|
@@ -327,11 +537,13 @@ export const FolderTreePicker = ({
|
|
|
327
537
|
createAbort.current?.abort();
|
|
328
538
|
const controller = new AbortController();
|
|
329
539
|
createAbort.current = controller;
|
|
330
|
-
|
|
540
|
+
const parentPath = draft.parentPath;
|
|
541
|
+
onCreateFolder(name, parentPath, controller.signal)
|
|
331
542
|
.then((created) => {
|
|
332
543
|
setCreating(false);
|
|
333
544
|
setDraft(null);
|
|
334
545
|
setDraftName("");
|
|
546
|
+
if (parentPath) setExpanded(parentPath, true);
|
|
335
547
|
onSelect(created.id);
|
|
336
548
|
})
|
|
337
549
|
.catch((error: unknown) => {
|
|
@@ -347,6 +559,7 @@ export const FolderTreePicker = ({
|
|
|
347
559
|
creating,
|
|
348
560
|
draftName,
|
|
349
561
|
onSelect,
|
|
562
|
+
setExpanded,
|
|
350
563
|
text.nameRequired,
|
|
351
564
|
text.createError,
|
|
352
565
|
]);
|
|
@@ -358,21 +571,43 @@ export const FolderTreePicker = ({
|
|
|
358
571
|
roving.current = true;
|
|
359
572
|
setFocusedIndex(next);
|
|
360
573
|
};
|
|
574
|
+
const focused = rows[focusedIndex];
|
|
361
575
|
switch (event.key) {
|
|
362
576
|
case "ArrowDown":
|
|
363
|
-
return move(
|
|
577
|
+
return move(findNextFocusable(rows, focusedIndex, 1));
|
|
364
578
|
case "ArrowUp":
|
|
365
|
-
return move(
|
|
579
|
+
return move(findNextFocusable(rows, focusedIndex, -1));
|
|
366
580
|
case "Home":
|
|
367
|
-
return move(
|
|
581
|
+
return move(findFirstFocusable(rows));
|
|
368
582
|
case "End":
|
|
369
|
-
return move(
|
|
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
|
+
}
|
|
370
606
|
case "Enter":
|
|
371
607
|
case " ": {
|
|
372
|
-
|
|
373
|
-
if (!isSelectable(target) || !target) return;
|
|
608
|
+
if (!isFocusable(focused) || !focused) return;
|
|
374
609
|
event.preventDefault();
|
|
375
|
-
|
|
610
|
+
activateRow(focused);
|
|
376
611
|
return;
|
|
377
612
|
}
|
|
378
613
|
case "Escape":
|
|
@@ -383,7 +618,7 @@ export const FolderTreePicker = ({
|
|
|
383
618
|
return;
|
|
384
619
|
}
|
|
385
620
|
},
|
|
386
|
-
[rows, focusedIndex,
|
|
621
|
+
[rows, focusedIndex, delimiter, activateRow, setExpanded, onCancel],
|
|
387
622
|
);
|
|
388
623
|
|
|
389
624
|
const draftForm = draft && (
|
|
@@ -442,8 +677,107 @@ export const FolderTreePicker = ({
|
|
|
442
677
|
</div>
|
|
443
678
|
);
|
|
444
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
|
+
|
|
445
779
|
return (
|
|
446
|
-
<div className="flex min-h-0 flex-col">
|
|
780
|
+
<div className="flex min-h-0 w-full min-w-0 flex-col">
|
|
447
781
|
<Input
|
|
448
782
|
variant="inline"
|
|
449
783
|
className="border-b border-line px-3 py-2"
|
|
@@ -459,9 +793,9 @@ export const FolderTreePicker = ({
|
|
|
459
793
|
}
|
|
460
794
|
if (event.key !== "ArrowDown") return;
|
|
461
795
|
event.preventDefault();
|
|
462
|
-
const first =
|
|
796
|
+
const first = isFocusable(rows[focusedIndex])
|
|
463
797
|
? focusedIndex
|
|
464
|
-
:
|
|
798
|
+
: findFirstFocusable(rows);
|
|
465
799
|
if (first < 0) return;
|
|
466
800
|
roving.current = true;
|
|
467
801
|
setFocusedIndex(first);
|
|
@@ -472,15 +806,14 @@ export const FolderTreePicker = ({
|
|
|
472
806
|
/>
|
|
473
807
|
|
|
474
808
|
{onCreateFolder && (
|
|
475
|
-
<div className="shrink-0">
|
|
476
|
-
<
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
</button>
|
|
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
|
+
/>
|
|
484
817
|
{draft?.anchorId === null && draftForm}
|
|
485
818
|
</div>
|
|
486
819
|
)}
|
|
@@ -500,91 +833,34 @@ export const FolderTreePicker = ({
|
|
|
500
833
|
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden"
|
|
501
834
|
onKeyDown={handleTreeKeyDown}
|
|
502
835
|
>
|
|
503
|
-
{
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
rowRefs.current[index] = node;
|
|
526
|
-
}}
|
|
527
|
-
type="button"
|
|
528
|
-
role="treeitem"
|
|
529
|
-
aria-level={depth + 1}
|
|
530
|
-
aria-selected={folder.id === selectedId}
|
|
531
|
-
aria-label={text.optionLabel(folder.label)}
|
|
532
|
-
tabIndex={index === focusedIndex ? 0 : -1}
|
|
533
|
-
onClick={() => onSelect(folder.id)}
|
|
534
|
-
onFocus={() => setFocusedIndex(index)}
|
|
535
|
-
className={cn(ROW_BASE, "hover:bg-surface-raised")}
|
|
536
|
-
>
|
|
537
|
-
{indent}
|
|
538
|
-
{icon}
|
|
539
|
-
<span className="min-w-0 flex-1 truncate">
|
|
540
|
-
{folder.label}
|
|
541
|
-
</span>
|
|
542
|
-
{folder.id === selectedId && (
|
|
543
|
-
<Check
|
|
544
|
-
className="size-4 shrink-0 text-accent"
|
|
545
|
-
aria-hidden="true"
|
|
546
|
-
/>
|
|
547
|
-
)}
|
|
548
|
-
</button>
|
|
549
|
-
) : (
|
|
550
|
-
// biome-ignore lint/a11y/useFocusableInteractive: a marker row, not a destination — focus belongs to the selectable rows
|
|
551
|
-
<div
|
|
552
|
-
role="treeitem"
|
|
553
|
-
aria-level={depth + 1}
|
|
554
|
-
aria-selected={false}
|
|
555
|
-
aria-current={folder.isCurrent ? "true" : undefined}
|
|
556
|
-
aria-label={`${folder.label} ${
|
|
557
|
-
folder.isCurrent
|
|
558
|
-
? text.currentSuffix
|
|
559
|
-
: text.contextSuffix
|
|
560
|
-
}`}
|
|
561
|
-
className={cn(ROW_BASE, "opacity-60")}
|
|
562
|
-
>
|
|
563
|
-
{indent}
|
|
564
|
-
{icon}
|
|
565
|
-
<span className="min-w-0 flex-1 truncate">
|
|
566
|
-
{folder.label}
|
|
567
|
-
</span>
|
|
568
|
-
{folder.isCurrent && (
|
|
569
|
-
<span className="shrink-0 text-xs text-fg-muted">
|
|
570
|
-
{text.currentTag}
|
|
571
|
-
</span>
|
|
572
|
-
)}
|
|
573
|
-
</div>
|
|
574
|
-
)}
|
|
575
|
-
{onCreateFolder && (
|
|
576
|
-
<button
|
|
577
|
-
type="button"
|
|
578
|
-
onClick={() => openDraft(folder)}
|
|
579
|
-
aria-label={text.newSubfolder(folder.label)}
|
|
580
|
-
title={text.newSubfolder(folder.label)}
|
|
581
|
-
className="flex size-11 shrink-0 items-center justify-center text-fg-subtle hover:bg-surface-raised hover:text-fg"
|
|
582
|
-
>
|
|
583
|
-
<FolderPlus className="size-4" aria-hidden="true" />
|
|
584
|
-
</button>
|
|
585
|
-
)}
|
|
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}
|
|
586
858
|
</div>
|
|
587
|
-
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
return (
|
|
862
|
+
<div key={entry.row.folder.id} role="none">
|
|
863
|
+
{renderFolderRow(entry.row, entry.index, separated)}
|
|
588
864
|
</div>
|
|
589
865
|
);
|
|
590
866
|
})}
|
|
@@ -595,16 +871,23 @@ export const FolderTreePicker = ({
|
|
|
595
871
|
};
|
|
596
872
|
|
|
597
873
|
/**
|
|
598
|
-
* Pure ordering, filtering and roving-focus helpers, exposed for unit
|
|
599
|
-
* without a DOM. Consumers should use {@link FolderTreePicker}.
|
|
874
|
+
* Pure ordering, filtering, expansion and roving-focus helpers, exposed for unit
|
|
875
|
+
* testing without a DOM. Consumers should use {@link FolderTreePicker}.
|
|
600
876
|
*/
|
|
601
877
|
export const folderTreePickerInternals = {
|
|
602
878
|
folderParent,
|
|
603
879
|
folderDepth,
|
|
880
|
+
folderAncestors,
|
|
604
881
|
orderFolderNodes,
|
|
605
882
|
filterFolderTree,
|
|
883
|
+
collapseFolderTree,
|
|
884
|
+
queryExpandedPaths,
|
|
885
|
+
withCreateRows,
|
|
606
886
|
matchesQuery,
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
887
|
+
isFocusable,
|
|
888
|
+
isSelectable,
|
|
889
|
+
findFirstFocusable,
|
|
890
|
+
findLastFocusable,
|
|
891
|
+
findNextFocusable,
|
|
892
|
+
findParentRow,
|
|
610
893
|
};
|