@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
- let parent = folderParent(path, delimiter);
188
- while (parent) {
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 findFirstSelectable = (rows: readonly FolderTreeRow[]): number => {
313
+ const findFirstFocusable = (rows: readonly FolderTreeRow[]): number => {
202
314
  for (let i = 0; i < rows.length; i += 1) {
203
- if (isSelectable(rows[i])) return i;
315
+ if (isFocusable(rows[i])) return i;
204
316
  }
205
317
  return -1;
206
318
  };
207
319
 
208
- const findLastSelectable = (rows: readonly FolderTreeRow[]): number => {
320
+ const findLastFocusable = (rows: readonly FolderTreeRow[]): number => {
209
321
  for (let i = rows.length - 1; i >= 0; i -= 1) {
210
- if (isSelectable(rows[i])) return i;
322
+ if (isFocusable(rows[i])) return i;
211
323
  }
212
324
  return -1;
213
325
  };
214
326
 
215
- const findNextSelectable = (
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 (isSelectable(rows[candidate])) return candidate;
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 you look through and tap,
239
- * with a filter for narrowing a long list and folder creation in place. Data
240
- * stays app-shaped — the kit owns ordering, filtering, focus and the create
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
- filterFolderTree(
271
- orderFolderNodes(folders, delimiter),
272
- trimmedQuery,
273
- delimiter,
274
- ),
275
- [folders, trimmedQuery, delimiter],
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
- isSelectable(rows[current]) ? current : findFirstSelectable(rows),
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
- onCreateFolder(name, draft.parentPath, controller.signal)
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(findNextSelectable(rows, focusedIndex, 1));
577
+ return move(findNextFocusable(rows, focusedIndex, 1));
364
578
  case "ArrowUp":
365
- return move(findNextSelectable(rows, focusedIndex, -1));
579
+ return move(findNextFocusable(rows, focusedIndex, -1));
366
580
  case "Home":
367
- return move(findFirstSelectable(rows));
581
+ return move(findFirstFocusable(rows));
368
582
  case "End":
369
- return move(findLastSelectable(rows));
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
- const target = rows[focusedIndex];
373
- if (!isSelectable(target) || !target) return;
608
+ if (!isFocusable(focused) || !focused) return;
374
609
  event.preventDefault();
375
- onSelect(target.folder.id);
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, onSelect, onCancel],
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 = isSelectable(rows[focusedIndex])
796
+ const first = isFocusable(rows[focusedIndex])
463
797
  ? focusedIndex
464
- : findFirstSelectable(rows);
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
- <button
477
- type="button"
478
- onClick={() => openDraft(null)}
479
- className="flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left text-sm font-medium text-accent-2 hover:bg-surface-raised"
480
- >
481
- <FolderPlus className="size-4 shrink-0" aria-hidden="true" />
482
- {text.newFolder}
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
- {rows.map((row, index) => {
504
- const { folder, depth } = row;
505
- const selectable = isSelectable(row);
506
- const indent = depth > 0 && (
507
- <span
508
- aria-hidden="true"
509
- className="shrink-0"
510
- style={{ width: depth * INDENT_STEP }}
511
- />
512
- );
513
- const icon = (
514
- <Folder
515
- className="size-4 shrink-0 text-fg-subtle"
516
- aria-hidden="true"
517
- />
518
- );
519
- return (
520
- <div key={folder.id} role="none">
521
- <div className="flex items-center">
522
- {selectable ? (
523
- <button
524
- ref={(node) => {
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
- {draft?.anchorId === folder.id && draftForm}
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 testing
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
- findFirstSelectable,
608
- findLastSelectable,
609
- findNextSelectable,
887
+ isFocusable,
888
+ isSelectable,
889
+ findFirstFocusable,
890
+ findLastFocusable,
891
+ findNextFocusable,
892
+ findParentRow,
610
893
  };