@remit/ui 0.0.67 → 0.0.69

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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/src/components/folder-manage-actions.render.test.ts +51 -0
  3. package/src/components/folder-manage-actions.stories.tsx +94 -0
  4. package/src/components/folder-manage-actions.tsx +54 -0
  5. package/src/components/folder-manager.render.test.ts +80 -0
  6. package/src/components/folder-manager.stories.tsx +184 -0
  7. package/src/components/folder-manager.tsx +77 -0
  8. package/src/components/folder-rename-dialog.render.test.ts +51 -0
  9. package/src/components/folder-rename-dialog.stories.tsx +66 -0
  10. package/src/components/folder-rename-dialog.tsx +95 -0
  11. package/src/components/folder-row.render.test.ts +88 -0
  12. package/src/components/folder-row.stories.tsx +118 -0
  13. package/src/components/folder-row.tsx +156 -0
  14. package/src/components/folder-tree-picker.create.test.ts +155 -8
  15. package/src/components/folder-tree-picker.render.test.ts +12 -284
  16. package/src/components/folder-tree-picker.stories.tsx +20 -7
  17. package/src/components/folder-tree-picker.tsx +130 -534
  18. package/src/components/new-folder-action.render.test.ts +44 -0
  19. package/src/components/new-folder-action.stories.tsx +92 -0
  20. package/src/components/new-folder-action.tsx +59 -0
  21. package/src/components/new-folder-form.render.test.ts +64 -0
  22. package/src/components/new-folder-form.stories.tsx +74 -0
  23. package/src/components/new-folder-form.tsx +126 -0
  24. package/src/index.ts +47 -0
  25. package/src/lib/folder-tree-focus.test.ts +98 -0
  26. package/src/lib/folder-tree-focus.ts +52 -0
  27. package/src/lib/folder-tree.test.ts +281 -0
  28. package/src/lib/folder-tree.ts +201 -0
@@ -1,36 +1,46 @@
1
- import { Check, ChevronRight, Folder, FolderPlus, Search } from "lucide-react";
1
+ import { Search } from "lucide-react";
2
2
  import {
3
3
  type KeyboardEvent as ReactKeyboardEvent,
4
+ type ReactNode,
4
5
  useCallback,
5
6
  useEffect,
6
- useId,
7
7
  useMemo,
8
8
  useRef,
9
9
  useState,
10
10
  } from "react";
11
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";
12
+ import {
13
+ collapseFolderTree,
14
+ type FolderTreeDisplayRow,
15
+ type FolderTreeNode,
16
+ type FolderTreeRow,
17
+ filterFolderTree,
18
+ folderAncestors,
19
+ orderFolderNodes,
20
+ queryExpandedPaths,
21
+ withCreateRows,
22
+ } from "../lib/folder-tree.js";
23
+ import {
24
+ findFirstFocusable,
25
+ findLastFocusable,
26
+ findNextFocusable,
27
+ findParentRow,
28
+ isFocusable,
29
+ isSelectable,
30
+ } from "../lib/folder-tree-focus.js";
31
+ import { FolderRow } from "./folder-row.js";
15
32
  import { Input } from "./input.js";
33
+ import { NewFolderAction } from "./new-folder-action.js";
34
+ import {
35
+ defaultNewFolderFormLabels,
36
+ NewFolderForm,
37
+ } from "./new-folder-form.js";
16
38
 
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
- }
39
+ export type {
40
+ FolderTreeDisplayRow,
41
+ FolderTreeNode,
42
+ FolderTreeRow,
43
+ } from "../lib/folder-tree.js";
34
44
 
35
45
  export interface FolderTreePickerLabels {
36
46
  filterPlaceholder?: string;
@@ -63,8 +73,14 @@ export interface FolderTreePickerProps {
63
73
  folders: readonly FolderTreeNode[];
64
74
  /** The destination chosen so far. */
65
75
  selectedId?: string;
66
- /** Marks the row. Choosing a destination advances nothing on its own. */
67
- onSelect: (folderId: string) => void;
76
+ /**
77
+ * Marks the row. Choosing a destination advances nothing on its own. Absent
78
+ * means the tree is browsed rather than chosen from, and a row does nothing
79
+ * beyond opening and closing.
80
+ */
81
+ onSelect?: (folderId: string) => void;
82
+ /** Controls beside each row, for a surface that acts on folders themselves. */
83
+ rowActions?: (folder: FolderTreeNode) => ReactNode;
68
84
  /**
69
85
  * Creating a folder is an IMAP mutation, so this resolves only once the mail
70
86
  * server confirms the folder (docs/architecture/imap-mutations.md). The form
@@ -86,6 +102,7 @@ export interface FolderTreePickerProps {
86
102
  }
87
103
 
88
104
  const defaultLabels: Required<FolderTreePickerLabels> = {
105
+ ...defaultNewFolderFormLabels,
89
106
  filterPlaceholder: "Filter folders…",
90
107
  filterAriaLabel: "Filter folders",
91
108
  treeAriaLabel: "Destination folders",
@@ -96,264 +113,11 @@ const defaultLabels: Required<FolderTreePickerLabels> = {
96
113
  optionLabel: (label) => `Move to ${label}`,
97
114
  newFolder: "New folder",
98
115
  newSubfolder: (label) => `New folder inside ${label}`,
99
- nameLabel: "Folder name",
100
- namePlaceholder: "Hotels",
101
- insideLabel: "Inside",
102
116
  topLevel: "Top level",
103
- create: "Create folder",
104
- cancel: "Cancel",
105
117
  nameRequired: "Give the folder a name.",
106
- createPending: "Creating folder…",
107
118
  createError: "Couldn't create that folder. Please try again.",
108
119
  };
109
120
 
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
121
  interface Draft {
358
122
  /** The row the form was opened from; `null` is the top-level row. */
359
123
  anchorId: string | null;
@@ -361,57 +125,6 @@ interface Draft {
361
125
  parentLabel: string;
362
126
  }
363
127
 
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
128
  /**
416
129
  * Browsable destination picker: the folders as a tree that starts at its top
417
130
  * level, opens a folder where you tap it, and makes a new folder wherever you
@@ -422,6 +135,7 @@ export const FolderTreePicker = ({
422
135
  folders,
423
136
  selectedId,
424
137
  onSelect,
138
+ rowActions,
425
139
  onCreateFolder,
426
140
  onCancel,
427
141
  delimiter = "/",
@@ -436,9 +150,7 @@ export const FolderTreePicker = ({
436
150
  const [creating, setCreating] = useState(false);
437
151
  const [focusedIndex, setFocusedIndex] = useState(-1);
438
152
 
439
- const nameFieldId = useId();
440
153
  const rowRefs = useRef<Array<HTMLButtonElement | null>>([]);
441
- const nameRef = useRef<HTMLInputElement>(null);
442
154
  const roving = useRef(false);
443
155
  const createAbort = useRef<AbortController | null>(null);
444
156
  useEffect(() => () => createAbort.current?.abort(), []);
@@ -481,18 +193,26 @@ export const FolderTreePicker = ({
481
193
  rowRefs.current[focusedIndex]?.focus();
482
194
  }, [focusedIndex]);
483
195
 
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
- }, []);
196
+ /**
197
+ * One open branch: opening a folder closes every folder that is not an
198
+ * ancestor of it, so the list never grows past what a phone screen holds.
199
+ * Its ancestors stay open because otherwise it could not be on screen.
200
+ */
201
+ const setExpanded = useCallback(
202
+ (path: string, open: boolean) => {
203
+ setOpened((current) => {
204
+ if (open) return new Set([...folderAncestors(path, delimiter), path]);
205
+ const next = new Set(current);
206
+ next.delete(path);
207
+ return next;
208
+ });
209
+ },
210
+ [delimiter],
211
+ );
492
212
 
493
213
  const activateRow = useCallback(
494
214
  (row: FolderTreeRow) => {
495
- if (isSelectable(row)) onSelect(row.folder.id);
215
+ if (isSelectable(row)) onSelect?.(row.folder.id);
496
216
  setExpanded(row.folder.path, !row.expanded);
497
217
  },
498
218
  [onSelect, setExpanded],
@@ -521,10 +241,6 @@ export const FolderTreePicker = ({
521
241
  [text.topLevel],
522
242
  );
523
243
 
524
- useEffect(() => {
525
- if (draft) nameRef.current?.focus();
526
- }, [draft]);
527
-
528
244
  const submitDraft = useCallback(() => {
529
245
  if (!onCreateFolder || !draft || creating) return;
530
246
  const name = draftName.trim();
@@ -544,7 +260,7 @@ export const FolderTreePicker = ({
544
260
  setDraft(null);
545
261
  setDraftName("");
546
262
  if (parentPath) setExpanded(parentPath, true);
547
- onSelect(created.id);
263
+ onSelect?.(created.id);
548
264
  })
549
265
  .catch((error: unknown) => {
550
266
  if (isAbortError(error)) return;
@@ -622,157 +338,49 @@ export const FolderTreePicker = ({
622
338
  );
623
339
 
624
340
  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>
341
+ <NewFolderForm
342
+ parentLabel={draft.parentLabel}
343
+ name={draftName}
344
+ onNameChange={(value) => {
345
+ setDraftName(value);
346
+ setDraftError(undefined);
347
+ }}
348
+ onSubmit={submitDraft}
349
+ onCancel={closeDraft}
350
+ pending={creating}
351
+ error={draftError}
352
+ labels={text}
353
+ />
678
354
  );
679
355
 
680
- const renderFolderRow = (
681
- row: FolderTreeRow,
682
- index: number,
683
- separated: boolean,
684
- ) => {
356
+ const rowAriaLabel = (row: FolderTreeRow): string => {
357
+ if (row.context) return `${row.folder.label} ${text.contextSuffix}`;
358
+ if (isSelectable(row)) return text.optionLabel(row.folder.label);
359
+ return `${row.folder.label} ${text.currentSuffix}`;
360
+ };
361
+
362
+ const folderRow = (row: FolderTreeRow, index: number, separated: boolean) => {
685
363
  const { folder, depth } = row;
686
364
  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
365
  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>
366
+ <FolderRow
367
+ ref={(node) => {
368
+ rowRefs.current[index] = node;
369
+ }}
370
+ label={folder.label}
371
+ depth={depth}
372
+ expanded={row.expanded}
373
+ context={row.context}
374
+ current={folder.isCurrent}
375
+ currentTag={text.currentTag}
376
+ selected={selectable && folder.id === selectedId}
377
+ separated={separated}
378
+ actions={rowActions?.(folder)}
379
+ ariaLabel={rowAriaLabel(row)}
380
+ tabIndex={index === focusedIndex ? 0 : -1}
381
+ onActivate={() => activateRow(row)}
382
+ onFocus={() => setFocusedIndex(index)}
383
+ />
776
384
  );
777
385
  };
778
386
 
@@ -806,15 +414,17 @@ export const FolderTreePicker = ({
806
414
  />
807
415
 
808
416
  {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}
417
+ <div className="shrink-0 border-b border-line" data-create-anchor="top">
418
+ {draft?.anchorId === null ? (
419
+ draftForm
420
+ ) : (
421
+ <NewFolderAction
422
+ label={text.newFolder}
423
+ ariaLabel={text.newFolder}
424
+ prominence="prominent"
425
+ onOpen={() => openDraft(null)}
426
+ />
427
+ )}
818
428
  </div>
819
429
  )}
820
430
 
@@ -824,13 +434,13 @@ export const FolderTreePicker = ({
824
434
  </p>
825
435
  ) : (
826
436
  // 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
437
+ // shows. The row wrapper is presentational and the row itself is the
438
+ // treeitem — a role on a wrapper around a separately-interactive
829
439
  // button is invalid ARIA.
830
440
  <div
831
441
  role="tree"
832
442
  aria-label={text.treeAriaLabel}
833
- className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden"
443
+ className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-24"
834
444
  onKeyDown={handleTreeKeyDown}
835
445
  >
836
446
  {(
@@ -846,21 +456,29 @@ export const FolderTreePicker = ({
846
456
  const separated = position < all.length - 1;
847
457
  if (entry.kind === "create") {
848
458
  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}
459
+ <div
460
+ key={`new:${entry.parent.id}`}
461
+ role="none"
462
+ data-create-anchor={entry.parent.id}
463
+ >
464
+ {draft?.anchorId === entry.parent.id ? (
465
+ draftForm
466
+ ) : (
467
+ <NewFolderAction
468
+ label={text.newFolder}
469
+ ariaLabel={text.newSubfolder(entry.parent.label)}
470
+ depth={entry.depth}
471
+ prominence="quiet"
472
+ separated={separated}
473
+ onOpen={() => openDraft(entry.parent)}
474
+ />
475
+ )}
858
476
  </div>
859
477
  );
860
478
  }
861
479
  return (
862
480
  <div key={entry.row.folder.id} role="none">
863
- {renderFolderRow(entry.row, entry.index, separated)}
481
+ {folderRow(entry.row, entry.index, separated)}
864
482
  </div>
865
483
  );
866
484
  })}
@@ -869,25 +487,3 @@ export const FolderTreePicker = ({
869
487
  </div>
870
488
  );
871
489
  };
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
- };