@remit/ui 0.0.66 → 0.0.68

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,36 +1,45 @@
1
- import { Check, Folder, FolderPlus, Search } from "lucide-react";
1
+ import { Search } from "lucide-react";
2
2
  import {
3
3
  type KeyboardEvent as ReactKeyboardEvent,
4
4
  useCallback,
5
5
  useEffect,
6
- useId,
7
6
  useMemo,
8
7
  useRef,
9
8
  useState,
10
9
  } from "react";
11
10
  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";
11
+ import {
12
+ collapseFolderTree,
13
+ type FolderTreeDisplayRow,
14
+ type FolderTreeNode,
15
+ type FolderTreeRow,
16
+ filterFolderTree,
17
+ folderAncestors,
18
+ orderFolderNodes,
19
+ queryExpandedPaths,
20
+ withCreateRows,
21
+ } from "../lib/folder-tree.js";
22
+ import {
23
+ findFirstFocusable,
24
+ findLastFocusable,
25
+ findNextFocusable,
26
+ findParentRow,
27
+ isFocusable,
28
+ isSelectable,
29
+ } from "../lib/folder-tree-focus.js";
30
+ import { FolderRow } from "./folder-row.js";
15
31
  import { Input } from "./input.js";
32
+ import { NewFolderAction } from "./new-folder-action.js";
33
+ import {
34
+ defaultNewFolderFormLabels,
35
+ NewFolderForm,
36
+ } from "./new-folder-form.js";
16
37
 
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
- }
38
+ export type {
39
+ FolderTreeDisplayRow,
40
+ FolderTreeNode,
41
+ FolderTreeRow,
42
+ } from "../lib/folder-tree.js";
34
43
 
35
44
  export interface FolderTreePickerLabels {
36
45
  filterPlaceholder?: string;
@@ -86,6 +95,7 @@ export interface FolderTreePickerProps {
86
95
  }
87
96
 
88
97
  const defaultLabels: Required<FolderTreePickerLabels> = {
98
+ ...defaultNewFolderFormLabels,
89
99
  filterPlaceholder: "Filter folders…",
90
100
  filterAriaLabel: "Filter folders",
91
101
  treeAriaLabel: "Destination folders",
@@ -96,137 +106,11 @@ const defaultLabels: Required<FolderTreePickerLabels> = {
96
106
  optionLabel: (label) => `Move to ${label}`,
97
107
  newFolder: "New folder",
98
108
  newSubfolder: (label) => `New folder inside ${label}`,
99
- nameLabel: "Folder name",
100
- namePlaceholder: "Hotels",
101
- insideLabel: "Inside",
102
109
  topLevel: "Top level",
103
- create: "Create folder",
104
- cancel: "Cancel",
105
110
  nameRequired: "Give the folder a name.",
106
- createPending: "Creating folder…",
107
111
  createError: "Couldn't create that folder. Please try again.",
108
112
  };
109
113
 
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
- const folderParent = (path: string, delimiter: string): string => {
116
- const cut = path.lastIndexOf(delimiter);
117
- return cut === -1 ? "" : path.slice(0, cut);
118
- };
119
-
120
- const folderDepth = (path: string, delimiter: string): number =>
121
- path.split(delimiter).length - 1;
122
-
123
- /**
124
- * Puts every child straight after its parent so the list reads as a tree, while
125
- * leaving the order of unrelated folders alone. A folder whose parent is absent
126
- * from the list renders as a root rather than disappearing.
127
- */
128
- const orderFolderNodes = (
129
- folders: readonly FolderTreeNode[],
130
- delimiter: string,
131
- ): FolderTreeNode[] => {
132
- const present = new Set(folders.map((folder) => folder.path));
133
- const emitted = new Set<string>();
134
- const out: FolderTreeNode[] = [];
135
-
136
- const emit = (folder: FolderTreeNode) => {
137
- if (emitted.has(folder.path)) return;
138
- emitted.add(folder.path);
139
- out.push(folder);
140
- for (const candidate of folders) {
141
- if (folderParent(candidate.path, delimiter) === folder.path) {
142
- emit(candidate);
143
- }
144
- }
145
- };
146
-
147
- for (const folder of folders) {
148
- const parent = folderParent(folder.path, delimiter);
149
- if (parent && present.has(parent)) continue;
150
- emit(folder);
151
- }
152
- return out;
153
- };
154
-
155
- const matchesQuery = (folder: FolderTreeNode, query: string): boolean =>
156
- folder.label.toLowerCase().includes(query) ||
157
- folder.path.toLowerCase().includes(query);
158
-
159
- export interface FolderTreeRow {
160
- folder: FolderTreeNode;
161
- depth: number;
162
- /**
163
- * On screen only to keep a match below it in place. It reads as the branch
164
- * it is, not as an answer to what was typed.
165
- */
166
- context: boolean;
167
- }
168
-
169
- const filterFolderTree = (
170
- ordered: readonly FolderTreeNode[],
171
- query: string,
172
- delimiter: string,
173
- ): FolderTreeRow[] => {
174
- const row = (folder: FolderTreeNode, context: boolean): FolderTreeRow => ({
175
- folder,
176
- depth: folderDepth(folder.path, delimiter),
177
- context,
178
- });
179
- if (!query) return ordered.map((folder) => row(folder, false));
180
-
181
- const matched = new Set<string>();
182
- for (const folder of ordered) {
183
- if (matchesQuery(folder, query)) matched.add(folder.path);
184
- }
185
- const visible = new Set(matched);
186
- for (const path of matched) {
187
- let parent = folderParent(path, delimiter);
188
- while (parent) {
189
- visible.add(parent);
190
- parent = folderParent(parent, delimiter);
191
- }
192
- }
193
- return ordered
194
- .filter((folder) => visible.has(folder.path))
195
- .map((folder) => row(folder, !matched.has(folder.path)));
196
- };
197
-
198
- const isSelectable = (row: FolderTreeRow | undefined): boolean =>
199
- row !== undefined && !row.folder.isCurrent && !row.context;
200
-
201
- const findFirstSelectable = (rows: readonly FolderTreeRow[]): number => {
202
- for (let i = 0; i < rows.length; i += 1) {
203
- if (isSelectable(rows[i])) return i;
204
- }
205
- return -1;
206
- };
207
-
208
- const findLastSelectable = (rows: readonly FolderTreeRow[]): number => {
209
- for (let i = rows.length - 1; i >= 0; i -= 1) {
210
- if (isSelectable(rows[i])) return i;
211
- }
212
- return -1;
213
- };
214
-
215
- const findNextSelectable = (
216
- rows: readonly FolderTreeRow[],
217
- from: number,
218
- step: 1 | -1,
219
- ): number => {
220
- const count = rows.length;
221
- if (count <= 0) return -1;
222
- const start = from < 0 ? (step === 1 ? -1 : count) : from;
223
- for (let offset = 1; offset <= count; offset += 1) {
224
- const candidate = (((start + step * offset) % count) + count) % count;
225
- if (isSelectable(rows[candidate])) return candidate;
226
- }
227
- return -1;
228
- };
229
-
230
114
  interface Draft {
231
115
  /** The row the form was opened from; `null` is the top-level row. */
232
116
  anchorId: string | null;
@@ -235,10 +119,10 @@ interface Draft {
235
119
  }
236
120
 
237
121
  /**
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.
122
+ * Browsable destination picker: the folders as a tree that starts at its top
123
+ * level, opens a folder where you tap it, and makes a new folder wherever you
124
+ * are looking. Data stays app-shaped — the kit owns ordering, filtering, focus
125
+ * and the create wait; the app owns labels, paths and the move itself.
242
126
  */
243
127
  export const FolderTreePicker = ({
244
128
  folders,
@@ -251,33 +135,46 @@ export const FolderTreePicker = ({
251
135
  }: FolderTreePickerProps) => {
252
136
  const text = { ...defaultLabels, ...labels };
253
137
  const [query, setQuery] = useState("");
138
+ const [opened, setOpened] = useState<ReadonlySet<string>>(new Set());
254
139
  const [draft, setDraft] = useState<Draft | null>(null);
255
140
  const [draftName, setDraftName] = useState("");
256
141
  const [draftError, setDraftError] = useState<string>();
257
142
  const [creating, setCreating] = useState(false);
258
143
  const [focusedIndex, setFocusedIndex] = useState(-1);
259
144
 
260
- const nameFieldId = useId();
261
145
  const rowRefs = useRef<Array<HTMLButtonElement | null>>([]);
262
- const nameRef = useRef<HTMLInputElement>(null);
263
146
  const roving = useRef(false);
264
147
  const createAbort = useRef<AbortController | null>(null);
265
148
  useEffect(() => () => createAbort.current?.abort(), []);
266
149
 
267
150
  const trimmedQuery = query.trim().toLowerCase();
151
+ const ordered = useMemo(
152
+ () => orderFolderNodes(folders, delimiter),
153
+ [folders, delimiter],
154
+ );
155
+
156
+ const expanded = useMemo(() => {
157
+ const auto = queryExpandedPaths(ordered, trimmedQuery, delimiter);
158
+ if (auto.size === 0) return opened;
159
+ return new Set([...opened, ...auto]);
160
+ }, [ordered, opened, trimmedQuery, delimiter]);
161
+
268
162
  const rows = useMemo(
269
163
  () =>
270
- filterFolderTree(
271
- orderFolderNodes(folders, delimiter),
272
- trimmedQuery,
273
- delimiter,
274
- ),
275
- [folders, trimmedQuery, delimiter],
164
+ trimmedQuery
165
+ ? filterFolderTree(ordered, trimmedQuery, delimiter, expanded)
166
+ : collapseFolderTree(ordered, expanded, delimiter),
167
+ [ordered, trimmedQuery, delimiter, expanded],
168
+ );
169
+
170
+ const displayRows = useMemo(
171
+ () => (onCreateFolder ? withCreateRows(rows, delimiter) : undefined),
172
+ [rows, delimiter, onCreateFolder],
276
173
  );
277
174
 
278
175
  useEffect(() => {
279
176
  setFocusedIndex((current) =>
280
- isSelectable(rows[current]) ? current : findFirstSelectable(rows),
177
+ isFocusable(rows[current]) ? current : findFirstFocusable(rows),
281
178
  );
282
179
  }, [rows]);
283
180
 
@@ -288,6 +185,31 @@ export const FolderTreePicker = ({
288
185
  rowRefs.current[focusedIndex]?.focus();
289
186
  }, [focusedIndex]);
290
187
 
188
+ /**
189
+ * One open branch: opening a folder closes every folder that is not an
190
+ * ancestor of it, so the list never grows past what a phone screen holds.
191
+ * Its ancestors stay open because otherwise it could not be on screen.
192
+ */
193
+ const setExpanded = useCallback(
194
+ (path: string, open: boolean) => {
195
+ setOpened((current) => {
196
+ if (open) return new Set([...folderAncestors(path, delimiter), path]);
197
+ const next = new Set(current);
198
+ next.delete(path);
199
+ return next;
200
+ });
201
+ },
202
+ [delimiter],
203
+ );
204
+
205
+ const activateRow = useCallback(
206
+ (row: FolderTreeRow) => {
207
+ if (isSelectable(row)) onSelect(row.folder.id);
208
+ setExpanded(row.folder.path, !row.expanded);
209
+ },
210
+ [onSelect, setExpanded],
211
+ );
212
+
291
213
  const closeDraft = useCallback(() => {
292
214
  createAbort.current?.abort();
293
215
  setDraft(null);
@@ -311,10 +233,6 @@ export const FolderTreePicker = ({
311
233
  [text.topLevel],
312
234
  );
313
235
 
314
- useEffect(() => {
315
- if (draft) nameRef.current?.focus();
316
- }, [draft]);
317
-
318
236
  const submitDraft = useCallback(() => {
319
237
  if (!onCreateFolder || !draft || creating) return;
320
238
  const name = draftName.trim();
@@ -327,11 +245,13 @@ export const FolderTreePicker = ({
327
245
  createAbort.current?.abort();
328
246
  const controller = new AbortController();
329
247
  createAbort.current = controller;
330
- onCreateFolder(name, draft.parentPath, controller.signal)
248
+ const parentPath = draft.parentPath;
249
+ onCreateFolder(name, parentPath, controller.signal)
331
250
  .then((created) => {
332
251
  setCreating(false);
333
252
  setDraft(null);
334
253
  setDraftName("");
254
+ if (parentPath) setExpanded(parentPath, true);
335
255
  onSelect(created.id);
336
256
  })
337
257
  .catch((error: unknown) => {
@@ -347,6 +267,7 @@ export const FolderTreePicker = ({
347
267
  creating,
348
268
  draftName,
349
269
  onSelect,
270
+ setExpanded,
350
271
  text.nameRequired,
351
272
  text.createError,
352
273
  ]);
@@ -358,21 +279,43 @@ export const FolderTreePicker = ({
358
279
  roving.current = true;
359
280
  setFocusedIndex(next);
360
281
  };
282
+ const focused = rows[focusedIndex];
361
283
  switch (event.key) {
362
284
  case "ArrowDown":
363
- return move(findNextSelectable(rows, focusedIndex, 1));
285
+ return move(findNextFocusable(rows, focusedIndex, 1));
364
286
  case "ArrowUp":
365
- return move(findNextSelectable(rows, focusedIndex, -1));
287
+ return move(findNextFocusable(rows, focusedIndex, -1));
366
288
  case "Home":
367
- return move(findFirstSelectable(rows));
289
+ return move(findFirstFocusable(rows));
368
290
  case "End":
369
- return move(findLastSelectable(rows));
291
+ return move(findLastFocusable(rows));
292
+ case "ArrowRight": {
293
+ if (!isFocusable(focused) || !focused) return;
294
+ event.preventDefault();
295
+ if (!focused.expanded) {
296
+ setExpanded(focused.folder.path, true);
297
+ return;
298
+ }
299
+ const next = focusedIndex + 1;
300
+ if (isFocusable(rows[next])) move(next);
301
+ return;
302
+ }
303
+ case "ArrowLeft": {
304
+ if (!isFocusable(focused) || !focused) return;
305
+ event.preventDefault();
306
+ if (focused.expanded) {
307
+ setExpanded(focused.folder.path, false);
308
+ return;
309
+ }
310
+ const parent = findParentRow(rows, focusedIndex, delimiter);
311
+ if (parent >= 0) move(parent);
312
+ return;
313
+ }
370
314
  case "Enter":
371
315
  case " ": {
372
- const target = rows[focusedIndex];
373
- if (!isSelectable(target) || !target) return;
316
+ if (!isFocusable(focused) || !focused) return;
374
317
  event.preventDefault();
375
- onSelect(target.folder.id);
318
+ activateRow(focused);
376
319
  return;
377
320
  }
378
321
  case "Escape":
@@ -383,67 +326,57 @@ export const FolderTreePicker = ({
383
326
  return;
384
327
  }
385
328
  },
386
- [rows, focusedIndex, onSelect, onCancel],
329
+ [rows, focusedIndex, delimiter, activateRow, setExpanded, onCancel],
387
330
  );
388
331
 
389
332
  const draftForm = draft && (
390
- <div className="space-y-3 border-y border-line bg-surface-sunken px-3 py-3">
391
- <div>
392
- <FieldLabel htmlFor={nameFieldId}>{text.nameLabel}</FieldLabel>
393
- <Input
394
- id={nameFieldId}
395
- ref={nameRef}
396
- value={draftName}
397
- placeholder={text.namePlaceholder}
398
- onChange={(event) => {
399
- setDraftName(event.target.value);
400
- setDraftError(undefined);
401
- }}
402
- onKeyDown={(event) => {
403
- if (event.key === "Enter") {
404
- event.preventDefault();
405
- submitDraft();
406
- }
407
- if (event.key === "Escape") {
408
- event.preventDefault();
409
- closeDraft();
410
- }
411
- }}
412
- />
413
- </div>
414
- <p className="text-xs text-fg-muted">
415
- {text.insideLabel}{" "}
416
- <span className="font-medium text-fg">{draft.parentLabel}</span>
417
- </p>
418
- {draftError && (
419
- <p className="text-xs text-danger" role="alert">
420
- {draftError}
421
- </p>
422
- )}
423
- <div className="flex items-center gap-2">
424
- <Button
425
- variant="ghost"
426
- size="touch"
427
- onClick={closeDraft}
428
- className="w-auto shrink-0 px-3"
429
- >
430
- {text.cancel}
431
- </Button>
432
- <Button
433
- variant="primary"
434
- size="touch"
435
- onClick={submitDraft}
436
- disabled={creating}
437
- className="w-auto flex-1 px-3"
438
- >
439
- {creating ? text.createPending : text.create}
440
- </Button>
441
- </div>
442
- </div>
333
+ <NewFolderForm
334
+ parentLabel={draft.parentLabel}
335
+ name={draftName}
336
+ onNameChange={(value) => {
337
+ setDraftName(value);
338
+ setDraftError(undefined);
339
+ }}
340
+ onSubmit={submitDraft}
341
+ onCancel={closeDraft}
342
+ pending={creating}
343
+ error={draftError}
344
+ labels={text}
345
+ />
443
346
  );
444
347
 
348
+ const rowAriaLabel = (row: FolderTreeRow): string => {
349
+ if (row.context) return `${row.folder.label} ${text.contextSuffix}`;
350
+ if (isSelectable(row)) return text.optionLabel(row.folder.label);
351
+ return `${row.folder.label} ${text.currentSuffix}`;
352
+ };
353
+
354
+ const folderRow = (row: FolderTreeRow, index: number, separated: boolean) => {
355
+ const { folder, depth } = row;
356
+ const selectable = isSelectable(row);
357
+ return (
358
+ <FolderRow
359
+ ref={(node) => {
360
+ rowRefs.current[index] = node;
361
+ }}
362
+ label={folder.label}
363
+ depth={depth}
364
+ expanded={row.expanded}
365
+ context={row.context}
366
+ current={folder.isCurrent}
367
+ currentTag={text.currentTag}
368
+ selected={selectable && folder.id === selectedId}
369
+ separated={separated}
370
+ ariaLabel={rowAriaLabel(row)}
371
+ tabIndex={index === focusedIndex ? 0 : -1}
372
+ onActivate={() => activateRow(row)}
373
+ onFocus={() => setFocusedIndex(index)}
374
+ />
375
+ );
376
+ };
377
+
445
378
  return (
446
- <div className="flex min-h-0 flex-col">
379
+ <div className="flex min-h-0 w-full min-w-0 flex-col">
447
380
  <Input
448
381
  variant="inline"
449
382
  className="border-b border-line px-3 py-2"
@@ -459,9 +392,9 @@ export const FolderTreePicker = ({
459
392
  }
460
393
  if (event.key !== "ArrowDown") return;
461
394
  event.preventDefault();
462
- const first = isSelectable(rows[focusedIndex])
395
+ const first = isFocusable(rows[focusedIndex])
463
396
  ? focusedIndex
464
- : findFirstSelectable(rows);
397
+ : findFirstFocusable(rows);
465
398
  if (first < 0) return;
466
399
  roving.current = true;
467
400
  setFocusedIndex(first);
@@ -472,16 +405,17 @@ export const FolderTreePicker = ({
472
405
  />
473
406
 
474
407
  {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>
484
- {draft?.anchorId === null && draftForm}
408
+ <div className="shrink-0 border-b border-line" data-create-anchor="top">
409
+ {draft?.anchorId === null ? (
410
+ draftForm
411
+ ) : (
412
+ <NewFolderAction
413
+ label={text.newFolder}
414
+ ariaLabel={text.newFolder}
415
+ prominence="prominent"
416
+ onOpen={() => openDraft(null)}
417
+ />
418
+ )}
485
419
  </div>
486
420
  )}
487
421
 
@@ -491,100 +425,51 @@ export const FolderTreePicker = ({
491
425
  </p>
492
426
  ) : (
493
427
  // A flattened tree: `aria-level` carries the nesting the indentation
494
- // shows. The row wrapper is presentational and the button itself is
495
- // the treeitem — a role on a wrapper around a separately-interactive
428
+ // shows. The row wrapper is presentational and the row itself is the
429
+ // treeitem — a role on a wrapper around a separately-interactive
496
430
  // button is invalid ARIA.
497
431
  <div
498
432
  role="tree"
499
433
  aria-label={text.treeAriaLabel}
500
- className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden"
434
+ className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-24"
501
435
  onKeyDown={handleTreeKeyDown}
502
436
  >
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>
437
+ {(
438
+ displayRows ??
439
+ rows.map(
440
+ (row, index): FolderTreeDisplayRow => ({
441
+ kind: "folder",
442
+ row,
443
+ index,
444
+ }),
445
+ )
446
+ ).map((entry, position, all) => {
447
+ const separated = position < all.length - 1;
448
+ if (entry.kind === "create") {
449
+ return (
450
+ <div
451
+ key={`new:${entry.parent.id}`}
452
+ role="none"
453
+ data-create-anchor={entry.parent.id}
454
+ >
455
+ {draft?.anchorId === entry.parent.id ? (
456
+ draftForm
549
457
  ) : (
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>
458
+ <NewFolderAction
459
+ label={text.newFolder}
460
+ ariaLabel={text.newSubfolder(entry.parent.label)}
461
+ depth={entry.depth}
462
+ prominence="quiet"
463
+ separated={separated}
464
+ onOpen={() => openDraft(entry.parent)}
465
+ />
585
466
  )}
586
467
  </div>
587
- {draft?.anchorId === folder.id && draftForm}
468
+ );
469
+ }
470
+ return (
471
+ <div key={entry.row.folder.id} role="none">
472
+ {folderRow(entry.row, entry.index, separated)}
588
473
  </div>
589
474
  );
590
475
  })}
@@ -593,18 +478,3 @@ export const FolderTreePicker = ({
593
478
  </div>
594
479
  );
595
480
  };
596
-
597
- /**
598
- * Pure ordering, filtering and roving-focus helpers, exposed for unit testing
599
- * without a DOM. Consumers should use {@link FolderTreePicker}.
600
- */
601
- export const folderTreePickerInternals = {
602
- folderParent,
603
- folderDepth,
604
- orderFolderNodes,
605
- filterFolderTree,
606
- matchesQuery,
607
- findFirstSelectable,
608
- findLastSelectable,
609
- findNextSelectable,
610
- };