@remit/ui 0.0.64 → 0.0.66
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-tree-picker.create.test.ts +362 -0
- package/src/components/folder-tree-picker.render.test.ts +241 -0
- package/src/components/folder-tree-picker.stories.tsx +329 -0
- package/src/components/folder-tree-picker.tsx +610 -0
- package/src/components/selection-wizard.render.test.ts +36 -0
- package/src/components/selection-wizard.tsx +33 -1
- package/src/index.ts +7 -0
- package/src/lib/wizard-steps.ts +8 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { Check, Folder, FolderPlus, Search } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
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";
|
|
15
|
+
import { Input } from "./input.js";
|
|
16
|
+
|
|
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
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FolderTreePickerLabels {
|
|
36
|
+
filterPlaceholder?: string;
|
|
37
|
+
filterAriaLabel?: string;
|
|
38
|
+
treeAriaLabel?: string;
|
|
39
|
+
/** Suffix announced for the current folder, e.g. `(current folder)`. */
|
|
40
|
+
currentSuffix?: string;
|
|
41
|
+
/** Inline tag shown on the current folder row. */
|
|
42
|
+
currentTag?: string;
|
|
43
|
+
/** Suffix announced for an ancestor held on screen by a match below it. */
|
|
44
|
+
contextSuffix?: string;
|
|
45
|
+
emptyMessage?: (query: string) => string;
|
|
46
|
+
/** Accessible label for a selectable row, e.g. `Move to X`. */
|
|
47
|
+
optionLabel?: (label: string) => string;
|
|
48
|
+
newFolder?: string;
|
|
49
|
+
newSubfolder?: (label: string) => string;
|
|
50
|
+
nameLabel?: string;
|
|
51
|
+
namePlaceholder?: string;
|
|
52
|
+
insideLabel?: string;
|
|
53
|
+
topLevel?: string;
|
|
54
|
+
create?: string;
|
|
55
|
+
cancel?: string;
|
|
56
|
+
nameRequired?: string;
|
|
57
|
+
createPending?: string;
|
|
58
|
+
createError?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FolderTreePickerProps {
|
|
62
|
+
/** Destinations as the app has them — labelled, and pathed by the provider. */
|
|
63
|
+
folders: readonly FolderTreeNode[];
|
|
64
|
+
/** The destination chosen so far. */
|
|
65
|
+
selectedId?: string;
|
|
66
|
+
/** Marks the row. Choosing a destination advances nothing on its own. */
|
|
67
|
+
onSelect: (folderId: string) => void;
|
|
68
|
+
/**
|
|
69
|
+
* Creating a folder is an IMAP mutation, so this resolves only once the mail
|
|
70
|
+
* server confirms the folder (docs/architecture/imap-mutations.md). The form
|
|
71
|
+
* holds the wait, refuses a second submit while it runs, states a failure
|
|
72
|
+
* where it happened, and aborts the signal on unmount so a late confirmation
|
|
73
|
+
* never selects a folder into a surface that is gone. Absent means no create
|
|
74
|
+
* affordance renders.
|
|
75
|
+
*/
|
|
76
|
+
onCreateFolder?: (
|
|
77
|
+
name: string,
|
|
78
|
+
parentPath: string,
|
|
79
|
+
signal?: AbortSignal,
|
|
80
|
+
) => Promise<FolderTreeNode>;
|
|
81
|
+
/** Escape. The picker never owns its presentation, so it cannot close itself. */
|
|
82
|
+
onCancel?: () => void;
|
|
83
|
+
/** The provider's hierarchy separator. */
|
|
84
|
+
delimiter?: string;
|
|
85
|
+
labels?: FolderTreePickerLabels;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const defaultLabels: Required<FolderTreePickerLabels> = {
|
|
89
|
+
filterPlaceholder: "Filter folders…",
|
|
90
|
+
filterAriaLabel: "Filter folders",
|
|
91
|
+
treeAriaLabel: "Destination folders",
|
|
92
|
+
currentSuffix: "(current folder)",
|
|
93
|
+
currentTag: "current",
|
|
94
|
+
contextSuffix: "(containing folder)",
|
|
95
|
+
emptyMessage: (query) => `No folders match "${query}"`,
|
|
96
|
+
optionLabel: (label) => `Move to ${label}`,
|
|
97
|
+
newFolder: "New folder",
|
|
98
|
+
newSubfolder: (label) => `New folder inside ${label}`,
|
|
99
|
+
nameLabel: "Folder name",
|
|
100
|
+
namePlaceholder: "Hotels",
|
|
101
|
+
insideLabel: "Inside",
|
|
102
|
+
topLevel: "Top level",
|
|
103
|
+
create: "Create folder",
|
|
104
|
+
cancel: "Cancel",
|
|
105
|
+
nameRequired: "Give the folder a name.",
|
|
106
|
+
createPending: "Creating folder…",
|
|
107
|
+
createError: "Couldn't create that folder. Please try again.",
|
|
108
|
+
};
|
|
109
|
+
|
|
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
|
+
interface Draft {
|
|
231
|
+
/** The row the form was opened from; `null` is the top-level row. */
|
|
232
|
+
anchorId: string | null;
|
|
233
|
+
parentPath: string;
|
|
234
|
+
parentLabel: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
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.
|
|
242
|
+
*/
|
|
243
|
+
export const FolderTreePicker = ({
|
|
244
|
+
folders,
|
|
245
|
+
selectedId,
|
|
246
|
+
onSelect,
|
|
247
|
+
onCreateFolder,
|
|
248
|
+
onCancel,
|
|
249
|
+
delimiter = "/",
|
|
250
|
+
labels,
|
|
251
|
+
}: FolderTreePickerProps) => {
|
|
252
|
+
const text = { ...defaultLabels, ...labels };
|
|
253
|
+
const [query, setQuery] = useState("");
|
|
254
|
+
const [draft, setDraft] = useState<Draft | null>(null);
|
|
255
|
+
const [draftName, setDraftName] = useState("");
|
|
256
|
+
const [draftError, setDraftError] = useState<string>();
|
|
257
|
+
const [creating, setCreating] = useState(false);
|
|
258
|
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
259
|
+
|
|
260
|
+
const nameFieldId = useId();
|
|
261
|
+
const rowRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
262
|
+
const nameRef = useRef<HTMLInputElement>(null);
|
|
263
|
+
const roving = useRef(false);
|
|
264
|
+
const createAbort = useRef<AbortController | null>(null);
|
|
265
|
+
useEffect(() => () => createAbort.current?.abort(), []);
|
|
266
|
+
|
|
267
|
+
const trimmedQuery = query.trim().toLowerCase();
|
|
268
|
+
const rows = useMemo(
|
|
269
|
+
() =>
|
|
270
|
+
filterFolderTree(
|
|
271
|
+
orderFolderNodes(folders, delimiter),
|
|
272
|
+
trimmedQuery,
|
|
273
|
+
delimiter,
|
|
274
|
+
),
|
|
275
|
+
[folders, trimmedQuery, delimiter],
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
setFocusedIndex((current) =>
|
|
280
|
+
isSelectable(rows[current]) ? current : findFirstSelectable(rows),
|
|
281
|
+
);
|
|
282
|
+
}, [rows]);
|
|
283
|
+
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (!roving.current) return;
|
|
286
|
+
roving.current = false;
|
|
287
|
+
if (focusedIndex < 0) return;
|
|
288
|
+
rowRefs.current[focusedIndex]?.focus();
|
|
289
|
+
}, [focusedIndex]);
|
|
290
|
+
|
|
291
|
+
const closeDraft = useCallback(() => {
|
|
292
|
+
createAbort.current?.abort();
|
|
293
|
+
setDraft(null);
|
|
294
|
+
setDraftName("");
|
|
295
|
+
setDraftError(undefined);
|
|
296
|
+
setCreating(false);
|
|
297
|
+
}, []);
|
|
298
|
+
|
|
299
|
+
const openDraft = useCallback(
|
|
300
|
+
(anchor: FolderTreeNode | null) => {
|
|
301
|
+
createAbort.current?.abort();
|
|
302
|
+
setDraft({
|
|
303
|
+
anchorId: anchor?.id ?? null,
|
|
304
|
+
parentPath: anchor?.path ?? "",
|
|
305
|
+
parentLabel: anchor?.label ?? text.topLevel,
|
|
306
|
+
});
|
|
307
|
+
setDraftName("");
|
|
308
|
+
setDraftError(undefined);
|
|
309
|
+
setCreating(false);
|
|
310
|
+
},
|
|
311
|
+
[text.topLevel],
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
if (draft) nameRef.current?.focus();
|
|
316
|
+
}, [draft]);
|
|
317
|
+
|
|
318
|
+
const submitDraft = useCallback(() => {
|
|
319
|
+
if (!onCreateFolder || !draft || creating) return;
|
|
320
|
+
const name = draftName.trim();
|
|
321
|
+
if (name === "") {
|
|
322
|
+
setDraftError(text.nameRequired);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
setCreating(true);
|
|
326
|
+
setDraftError(undefined);
|
|
327
|
+
createAbort.current?.abort();
|
|
328
|
+
const controller = new AbortController();
|
|
329
|
+
createAbort.current = controller;
|
|
330
|
+
onCreateFolder(name, draft.parentPath, controller.signal)
|
|
331
|
+
.then((created) => {
|
|
332
|
+
setCreating(false);
|
|
333
|
+
setDraft(null);
|
|
334
|
+
setDraftName("");
|
|
335
|
+
onSelect(created.id);
|
|
336
|
+
})
|
|
337
|
+
.catch((error: unknown) => {
|
|
338
|
+
if (isAbortError(error)) return;
|
|
339
|
+
setDraftError(
|
|
340
|
+
error instanceof Error ? error.message : text.createError,
|
|
341
|
+
);
|
|
342
|
+
setCreating(false);
|
|
343
|
+
});
|
|
344
|
+
}, [
|
|
345
|
+
onCreateFolder,
|
|
346
|
+
draft,
|
|
347
|
+
creating,
|
|
348
|
+
draftName,
|
|
349
|
+
onSelect,
|
|
350
|
+
text.nameRequired,
|
|
351
|
+
text.createError,
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
const handleTreeKeyDown = useCallback(
|
|
355
|
+
(event: ReactKeyboardEvent<HTMLElement>) => {
|
|
356
|
+
const move = (next: number) => {
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
roving.current = true;
|
|
359
|
+
setFocusedIndex(next);
|
|
360
|
+
};
|
|
361
|
+
switch (event.key) {
|
|
362
|
+
case "ArrowDown":
|
|
363
|
+
return move(findNextSelectable(rows, focusedIndex, 1));
|
|
364
|
+
case "ArrowUp":
|
|
365
|
+
return move(findNextSelectable(rows, focusedIndex, -1));
|
|
366
|
+
case "Home":
|
|
367
|
+
return move(findFirstSelectable(rows));
|
|
368
|
+
case "End":
|
|
369
|
+
return move(findLastSelectable(rows));
|
|
370
|
+
case "Enter":
|
|
371
|
+
case " ": {
|
|
372
|
+
const target = rows[focusedIndex];
|
|
373
|
+
if (!isSelectable(target) || !target) return;
|
|
374
|
+
event.preventDefault();
|
|
375
|
+
onSelect(target.folder.id);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
case "Escape":
|
|
379
|
+
event.preventDefault();
|
|
380
|
+
onCancel?.();
|
|
381
|
+
return;
|
|
382
|
+
default:
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
[rows, focusedIndex, onSelect, onCancel],
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
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>
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
return (
|
|
446
|
+
<div className="flex min-h-0 flex-col">
|
|
447
|
+
<Input
|
|
448
|
+
variant="inline"
|
|
449
|
+
className="border-b border-line px-3 py-2"
|
|
450
|
+
icon={<Search className="size-4" aria-hidden="true" />}
|
|
451
|
+
type="search"
|
|
452
|
+
value={query}
|
|
453
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
454
|
+
onKeyDown={(event) => {
|
|
455
|
+
if (event.key === "Escape") {
|
|
456
|
+
event.preventDefault();
|
|
457
|
+
onCancel?.();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (event.key !== "ArrowDown") return;
|
|
461
|
+
event.preventDefault();
|
|
462
|
+
const first = isSelectable(rows[focusedIndex])
|
|
463
|
+
? focusedIndex
|
|
464
|
+
: findFirstSelectable(rows);
|
|
465
|
+
if (first < 0) return;
|
|
466
|
+
roving.current = true;
|
|
467
|
+
setFocusedIndex(first);
|
|
468
|
+
rowRefs.current[first]?.focus();
|
|
469
|
+
}}
|
|
470
|
+
placeholder={text.filterPlaceholder}
|
|
471
|
+
aria-label={text.filterAriaLabel}
|
|
472
|
+
/>
|
|
473
|
+
|
|
474
|
+
{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}
|
|
485
|
+
</div>
|
|
486
|
+
)}
|
|
487
|
+
|
|
488
|
+
{rows.length === 0 ? (
|
|
489
|
+
<p className="px-3 py-3 text-sm text-fg-muted" aria-live="polite">
|
|
490
|
+
{text.emptyMessage(query)}
|
|
491
|
+
</p>
|
|
492
|
+
) : (
|
|
493
|
+
// 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
|
|
496
|
+
// button is invalid ARIA.
|
|
497
|
+
<div
|
|
498
|
+
role="tree"
|
|
499
|
+
aria-label={text.treeAriaLabel}
|
|
500
|
+
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden"
|
|
501
|
+
onKeyDown={handleTreeKeyDown}
|
|
502
|
+
>
|
|
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
|
+
)}
|
|
586
|
+
</div>
|
|
587
|
+
{draft?.anchorId === folder.id && draftForm}
|
|
588
|
+
</div>
|
|
589
|
+
);
|
|
590
|
+
})}
|
|
591
|
+
</div>
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
);
|
|
595
|
+
};
|
|
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
|
+
};
|
|
@@ -625,6 +625,42 @@ describe("RunFooter", () => {
|
|
|
625
625
|
);
|
|
626
626
|
assert.match(html, /Close/);
|
|
627
627
|
});
|
|
628
|
+
|
|
629
|
+
// Leaving the screen and ending the run are two intents (#521). The screen
|
|
630
|
+
// says the run keeps going once it is closed, so the only thing that ends it
|
|
631
|
+
// is a control that says so.
|
|
632
|
+
it("offers stopping the run beside leaving it, while it is in flight", () => {
|
|
633
|
+
const html = renderToString(
|
|
634
|
+
createElement(RunFooter, {
|
|
635
|
+
...runProps,
|
|
636
|
+
state: "backApplyRunning",
|
|
637
|
+
onCancelRun: noop,
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
assert.match(html, /Stop the run/);
|
|
641
|
+
assert.match(html, /Close/);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("offers only the way out where there is no run to stop", () => {
|
|
645
|
+
const html = renderToString(
|
|
646
|
+
createElement(RunFooter, { ...runProps, state: "backApplyRunning" }),
|
|
647
|
+
);
|
|
648
|
+
assert.match(html, /Close/);
|
|
649
|
+
assert.doesNotMatch(html, /Stop the run/);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("keeps the retry on a run that already stopped", () => {
|
|
653
|
+
const html = renderToString(
|
|
654
|
+
createElement(RunFooter, {
|
|
655
|
+
...runProps,
|
|
656
|
+
state: "runStopped",
|
|
657
|
+
failures: messages,
|
|
658
|
+
onCancelRun: noop,
|
|
659
|
+
}),
|
|
660
|
+
);
|
|
661
|
+
assert.match(html, /Retry 2/);
|
|
662
|
+
assert.doesNotMatch(html, /Stop the run/);
|
|
663
|
+
});
|
|
628
664
|
});
|
|
629
665
|
|
|
630
666
|
describe("SelectionWizard", () => {
|
|
@@ -967,6 +967,13 @@ export interface RunStepProps {
|
|
|
967
967
|
failedCount?: number;
|
|
968
968
|
onRetry: () => void;
|
|
969
969
|
onDismiss: () => void;
|
|
970
|
+
/**
|
|
971
|
+
* Ends the run. Leaving the screen and ending the run are two intents and
|
|
972
|
+
* two presses: the screen says the run keeps going once it is closed, so
|
|
973
|
+
* stopping it has to be asked for. Supplied only where there is something
|
|
974
|
+
* to stop — a server-side pass runs to its own end.
|
|
975
|
+
*/
|
|
976
|
+
onCancelRun?: () => void;
|
|
970
977
|
}
|
|
971
978
|
|
|
972
979
|
const runOutcomeOf = (props: RunStepProps): RunOutcome => ({
|
|
@@ -1042,9 +1049,34 @@ export function RunStepBody(props: RunStepProps) {
|
|
|
1042
1049
|
}
|
|
1043
1050
|
|
|
1044
1051
|
export function RunFooter(props: RunStepProps) {
|
|
1045
|
-
const { onRetry, onDismiss } = props;
|
|
1052
|
+
const { onRetry, onDismiss, onCancelRun } = props;
|
|
1046
1053
|
const copy = runCopy(runOutcomeOf(props));
|
|
1047
1054
|
|
|
1055
|
+
// Closing is the movement the screen invites, so it keeps the primary place;
|
|
1056
|
+
// ending the run is the deliberate one beside it.
|
|
1057
|
+
if (copy.cancelLabel !== undefined && onCancelRun) {
|
|
1058
|
+
return (
|
|
1059
|
+
<div className="flex items-center gap-3">
|
|
1060
|
+
<Button
|
|
1061
|
+
variant="ghost"
|
|
1062
|
+
size="md"
|
|
1063
|
+
className="min-h-11 shrink-0"
|
|
1064
|
+
onClick={onCancelRun}
|
|
1065
|
+
>
|
|
1066
|
+
{copy.cancelLabel}
|
|
1067
|
+
</Button>
|
|
1068
|
+
<Button
|
|
1069
|
+
variant="primary"
|
|
1070
|
+
size="md"
|
|
1071
|
+
className="min-h-11 flex-1"
|
|
1072
|
+
onClick={onDismiss}
|
|
1073
|
+
>
|
|
1074
|
+
{copy.dismissLabel}
|
|
1075
|
+
</Button>
|
|
1076
|
+
</div>
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1048
1080
|
if (copy.retryLabel === undefined) {
|
|
1049
1081
|
return (
|
|
1050
1082
|
<Button
|
package/src/index.ts
CHANGED
|
@@ -207,6 +207,13 @@ export {
|
|
|
207
207
|
type ResultFolder,
|
|
208
208
|
roleIcon,
|
|
209
209
|
} from "./components/folder-role.js";
|
|
210
|
+
export {
|
|
211
|
+
type FolderTreeNode,
|
|
212
|
+
FolderTreePicker,
|
|
213
|
+
type FolderTreePickerLabels,
|
|
214
|
+
type FolderTreePickerProps,
|
|
215
|
+
type FolderTreeRow,
|
|
216
|
+
} from "./components/folder-tree-picker.js";
|
|
210
217
|
export {
|
|
211
218
|
Input,
|
|
212
219
|
type InputProps,
|