@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.
- package/package.json +1 -1
- package/src/components/folder-row.render.test.ts +81 -0
- package/src/components/folder-row.stories.tsx +118 -0
- package/src/components/folder-row.tsx +147 -0
- package/src/components/folder-tree-picker.create.test.ts +341 -63
- package/src/components/folder-tree-picker.render.test.ts +21 -155
- package/src/components/folder-tree-picker.stories.tsx +56 -10
- package/src/components/folder-tree-picker.tsx +210 -340
- package/src/components/new-folder-action.render.test.ts +44 -0
- package/src/components/new-folder-action.stories.tsx +92 -0
- package/src/components/new-folder-action.tsx +59 -0
- package/src/components/new-folder-form.render.test.ts +64 -0
- package/src/components/new-folder-form.stories.tsx +74 -0
- package/src/components/new-folder-form.tsx +126 -0
- package/src/index.ts +33 -0
- package/src/lib/folder-tree-focus.test.ts +98 -0
- package/src/lib/folder-tree-focus.ts +52 -0
- package/src/lib/folder-tree.test.ts +281 -0
- package/src/lib/folder-tree.ts +201 -0
|
@@ -1,36 +1,45 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
239
|
-
*
|
|
240
|
-
* stays app-shaped — the kit owns ordering, filtering, focus
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
285
|
+
return move(findNextFocusable(rows, focusedIndex, 1));
|
|
364
286
|
case "ArrowUp":
|
|
365
|
-
return move(
|
|
287
|
+
return move(findNextFocusable(rows, focusedIndex, -1));
|
|
366
288
|
case "Home":
|
|
367
|
-
return move(
|
|
289
|
+
return move(findFirstFocusable(rows));
|
|
368
290
|
case "End":
|
|
369
|
-
return move(
|
|
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
|
-
|
|
373
|
-
if (!isSelectable(target) || !target) return;
|
|
316
|
+
if (!isFocusable(focused) || !focused) return;
|
|
374
317
|
event.preventDefault();
|
|
375
|
-
|
|
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,
|
|
329
|
+
[rows, focusedIndex, delimiter, activateRow, setExpanded, onCancel],
|
|
387
330
|
);
|
|
388
331
|
|
|
389
332
|
const draftForm = draft && (
|
|
390
|
-
<
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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 =
|
|
395
|
+
const first = isFocusable(rows[focusedIndex])
|
|
463
396
|
? focusedIndex
|
|
464
|
-
:
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
495
|
-
//
|
|
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
|
-
{
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
};
|