@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
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import {
6
+ NewFolderAction,
7
+ type NewFolderActionProps,
8
+ } from "./new-folder-action.js";
9
+
10
+ const render = (props: Partial<NewFolderActionProps>) =>
11
+ renderToString(
12
+ createElement(NewFolderAction, {
13
+ label: "New folder",
14
+ ariaLabel: "New folder",
15
+ onOpen: () => {},
16
+ ...props,
17
+ }),
18
+ );
19
+
20
+ describe("NewFolderAction", () => {
21
+ it("says what it makes, and where", () => {
22
+ const html = render({ ariaLabel: "New folder inside Travel" });
23
+ assert.match(html, /<button[^>]*aria-label="New folder inside Travel"/);
24
+ assert.match(html, />New folder</);
25
+ });
26
+
27
+ it("takes the dashed add affordance where it is the pinned action", () => {
28
+ const html = render({});
29
+ assert.match(html, /data-prominence="prominent"/);
30
+ assert.match(html, /border-dashed/);
31
+ });
32
+
33
+ it("stays neutral where it is the last thing inside a folder", () => {
34
+ const html = render({ prominence: "quiet", depth: 1 });
35
+ assert.match(html, /data-prominence="quiet"/);
36
+ assert.doesNotMatch(html, /border-dashed/);
37
+ assert.match(html, /width:14px/);
38
+ });
39
+
40
+ it("insets the hairline to where the label starts", () => {
41
+ assert.match(render({ separated: true, depth: 1 }), /left:74px/);
42
+ assert.doesNotMatch(render({ depth: 1 }), /left:74px/);
43
+ });
44
+ });
@@ -0,0 +1,92 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { ReactNode } from "react";
3
+ import { FolderRow } from "./folder-row.js";
4
+ import { NewFolderAction } from "./new-folder-action.js";
5
+
6
+ const meta: Meta<typeof NewFolderAction> = {
7
+ title: "Mail/NewFolderAction",
8
+ component: NewFolderAction,
9
+ parameters: { layout: "centered" },
10
+ };
11
+ export default meta;
12
+
13
+ type Story = StoryObj<typeof NewFolderAction>;
14
+
15
+ function List({ children }: { children: ReactNode }) {
16
+ return (
17
+ <div className="w-[320px] overflow-hidden rounded-lg border border-line bg-surface font-sans text-fg">
18
+ {children}
19
+ </div>
20
+ );
21
+ }
22
+
23
+ /** Pinned above a list, where it is the loudest thing a folder tree offers. */
24
+ export const Prominent: Story = {
25
+ name: "Prominent (pinned above the list)",
26
+ render: () => (
27
+ <List>
28
+ <NewFolderAction
29
+ label="New folder"
30
+ ariaLabel="New folder"
31
+ onOpen={() => undefined}
32
+ />
33
+ </List>
34
+ ),
35
+ };
36
+
37
+ /** The last thing inside an opened folder, subordinate to the folder it sits in. */
38
+ export const Quiet: Story = {
39
+ name: "Quiet (inside an opened folder)",
40
+ render: () => (
41
+ <List>
42
+ <FolderRow
43
+ label="Travel"
44
+ depth={0}
45
+ expanded
46
+ ariaLabel="Move to Travel"
47
+ separated
48
+ />
49
+ <FolderRow
50
+ label="Hotels"
51
+ depth={1}
52
+ expanded={false}
53
+ ariaLabel="Move to Hotels"
54
+ separated
55
+ />
56
+ <NewFolderAction
57
+ label="New folder"
58
+ ariaLabel="New folder inside Travel"
59
+ depth={1}
60
+ prominence="quiet"
61
+ onOpen={() => undefined}
62
+ />
63
+ </List>
64
+ ),
65
+ };
66
+
67
+ export const BothTreatments: Story = {
68
+ name: "Both treatments at once",
69
+ render: () => (
70
+ <List>
71
+ <NewFolderAction
72
+ label="New folder"
73
+ ariaLabel="New folder"
74
+ onOpen={() => undefined}
75
+ />
76
+ <FolderRow
77
+ label="Finance"
78
+ depth={0}
79
+ expanded
80
+ ariaLabel="Move to Finance"
81
+ separated
82
+ />
83
+ <NewFolderAction
84
+ label="New folder"
85
+ ariaLabel="New folder inside Finance"
86
+ depth={1}
87
+ prominence="quiet"
88
+ onOpen={() => undefined}
89
+ />
90
+ </List>
91
+ ),
92
+ };
@@ -0,0 +1,59 @@
1
+ import { FolderPlus } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import {
4
+ FOLDER_ROW_BASE,
5
+ FolderRowIndent,
6
+ FolderRowSeparator,
7
+ } from "./folder-row.js";
8
+
9
+ /**
10
+ * How loudly a create action asks to be noticed. The pinned top-level one takes
11
+ * the kit's dashed "add" affordance; the one inside an opened folder stays
12
+ * neutral so it reads as the last thing in that folder rather than as a rival
13
+ * to the pinned one.
14
+ */
15
+ export type NewFolderProminence = "prominent" | "quiet";
16
+
17
+ export interface NewFolderActionProps {
18
+ label: string;
19
+ /** Names the folder it makes one inside, e.g. `New folder inside Travel`. */
20
+ ariaLabel: string;
21
+ /** Indents it to sit among the children of the folder it belongs to. */
22
+ depth?: number;
23
+ prominence?: NewFolderProminence;
24
+ /** A hairline under the label, drawn for every row but the last. */
25
+ separated?: boolean;
26
+ onOpen: () => void;
27
+ }
28
+
29
+ /** Makes a folder where the user is looking, as a row in the list. */
30
+ export const NewFolderAction = ({
31
+ label,
32
+ ariaLabel,
33
+ depth = 0,
34
+ prominence = "prominent",
35
+ separated = false,
36
+ onOpen,
37
+ }: NewFolderActionProps) => (
38
+ <div className={cn("relative", prominence === "prominent" && "p-2")}>
39
+ <button
40
+ type="button"
41
+ onClick={onOpen}
42
+ aria-label={ariaLabel}
43
+ data-prominence={prominence}
44
+ className={cn(
45
+ FOLDER_ROW_BASE,
46
+ "w-full active:bg-surface-sunken",
47
+ prominence === "prominent"
48
+ ? "rounded-lg border border-line-strong border-dashed font-medium text-fg-muted hover:border-accent-2 hover:text-accent-2"
49
+ : "text-fg-subtle hover:bg-surface-raised hover:text-fg-muted",
50
+ )}
51
+ >
52
+ <FolderRowIndent depth={depth} />
53
+ <span aria-hidden="true" className="size-4 shrink-0" />
54
+ <FolderPlus className="size-4 shrink-0" aria-hidden="true" />
55
+ <span className="min-w-0 flex-1 truncate">{label}</span>
56
+ </button>
57
+ {separated && <FolderRowSeparator depth={depth} />}
58
+ </div>
59
+ );
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { NewFolderForm, type NewFolderFormProps } from "./new-folder-form.js";
6
+
7
+ const render = (props: Partial<NewFolderFormProps>) =>
8
+ renderToString(
9
+ createElement(NewFolderForm, {
10
+ parentLabel: "Travel",
11
+ name: "",
12
+ onNameChange: () => {},
13
+ onSubmit: () => {},
14
+ onCancel: () => {},
15
+ ...props,
16
+ }),
17
+ );
18
+
19
+ describe("NewFolderForm", () => {
20
+ it("asks for a name and states where the folder goes", () => {
21
+ const html = render({ name: "Car hire" });
22
+ assert.match(html, /Folder name/);
23
+ assert.match(html, /value="Car hire"/);
24
+ assert.match(html, /placeholder="Hotels"/);
25
+ assert.match(html, /Inside/);
26
+ assert.match(html, />Travel</);
27
+ });
28
+
29
+ it("ties the label to the field it names", () => {
30
+ const html = render({});
31
+ const forId = html.match(/<label[^>]*for="([^"]+)"/)?.[1];
32
+ assert.ok(forId);
33
+ assert.match(html, new RegExp(`<input[^>]*id="${forId}"`));
34
+ });
35
+
36
+ it("states the parent as text rather than a second choice", () => {
37
+ assert.doesNotMatch(render({}), /<select/);
38
+ });
39
+
40
+ it("holds the wait and refuses a second submit while it runs", () => {
41
+ const html = render({ pending: true });
42
+ assert.match(html, /Creating folder…/);
43
+ assert.match(html, /<button[^>]*disabled/);
44
+ assert.doesNotMatch(html, />Create folder</);
45
+ });
46
+
47
+ it("states a failure where it happened", () => {
48
+ const html = render({ error: "The mail server refused that name." });
49
+ assert.match(html, /role="alert"[^>]*>The mail server refused that name\./);
50
+ });
51
+
52
+ it("says nothing about a failure that has not happened", () => {
53
+ assert.doesNotMatch(render({}), /role="alert"/);
54
+ });
55
+
56
+ it("applies caller-supplied labels", () => {
57
+ const html = render({
58
+ labels: { create: "Map maken", cancel: "Annuleren", insideLabel: "In" },
59
+ });
60
+ assert.match(html, />Map maken</);
61
+ assert.match(html, />Annuleren</);
62
+ assert.doesNotMatch(html, /Inside/);
63
+ });
64
+ });
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { type ReactNode, useState } from "react";
3
+ import { NewFolderForm } from "./new-folder-form.js";
4
+
5
+ const meta: Meta<typeof NewFolderForm> = {
6
+ title: "Mail/NewFolderForm",
7
+ component: NewFolderForm,
8
+ parameters: { layout: "centered" },
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof NewFolderForm>;
13
+
14
+ function Frame({ children }: { children: ReactNode }) {
15
+ return (
16
+ <div className="w-[320px] overflow-hidden rounded-lg border border-line bg-surface font-sans text-fg">
17
+ {children}
18
+ </div>
19
+ );
20
+ }
21
+
22
+ function Form({
23
+ parentLabel = "Travel",
24
+ initialName = "",
25
+ pending,
26
+ error,
27
+ }: {
28
+ parentLabel?: string;
29
+ initialName?: string;
30
+ pending?: boolean;
31
+ error?: string;
32
+ }) {
33
+ const [name, setName] = useState(initialName);
34
+ return (
35
+ <Frame>
36
+ <NewFolderForm
37
+ parentLabel={parentLabel}
38
+ name={name}
39
+ onNameChange={setName}
40
+ onSubmit={() => undefined}
41
+ onCancel={() => undefined}
42
+ pending={pending}
43
+ error={error}
44
+ />
45
+ </Frame>
46
+ );
47
+ }
48
+
49
+ export const Empty: Story = {
50
+ name: "Opened, waiting for a name",
51
+ render: () => <Form />,
52
+ };
53
+
54
+ export const TopLevel: Story = {
55
+ name: "At the top level",
56
+ render: () => <Form parentLabel="Top level" initialName="Insurance" />,
57
+ };
58
+
59
+ /** Creating a folder is an IMAP mutation: the form holds until the server confirms. */
60
+ export const Pending: Story = {
61
+ name: "Waiting for the server",
62
+ render: () => <Form initialName="Car hire" pending />,
63
+ };
64
+
65
+ /** The failure is stated where it happened; the form stays open to retry. */
66
+ export const Failed: Story = {
67
+ name: "Failed (retry in place)",
68
+ render: () => (
69
+ <Form
70
+ initialName="Car hire"
71
+ error="The mail server refused the folder name. Try another one."
72
+ />
73
+ ),
74
+ };
@@ -0,0 +1,126 @@
1
+ import { useEffect, useId, useRef } from "react";
2
+ import { Button } from "./button.js";
3
+ import { FieldLabel } from "./field-label.js";
4
+ import { Input } from "./input.js";
5
+
6
+ export interface NewFolderFormLabels {
7
+ nameLabel?: string;
8
+ namePlaceholder?: string;
9
+ /** Precedes the parent, e.g. `Inside Travel`. */
10
+ insideLabel?: string;
11
+ create?: string;
12
+ cancel?: string;
13
+ createPending?: string;
14
+ }
15
+
16
+ export const defaultNewFolderFormLabels: Required<NewFolderFormLabels> = {
17
+ nameLabel: "Folder name",
18
+ namePlaceholder: "Hotels",
19
+ insideLabel: "Inside",
20
+ create: "Create folder",
21
+ cancel: "Cancel",
22
+ createPending: "Creating folder…",
23
+ };
24
+
25
+ export interface NewFolderFormProps {
26
+ /** Where the folder is made, stated as text rather than a second choice. */
27
+ parentLabel: string;
28
+ name: string;
29
+ onNameChange: (name: string) => void;
30
+ onSubmit: () => void;
31
+ onCancel: () => void;
32
+ /** The mail server has the request and has not confirmed it yet. */
33
+ pending?: boolean;
34
+ error?: string;
35
+ labels?: NewFolderFormLabels;
36
+ }
37
+
38
+ /**
39
+ * Names a folder and holds the wait while the mail server makes it. Opens
40
+ * focused and in view, states a failure in place, and refuses a second submit
41
+ * while the first is still out.
42
+ */
43
+ export const NewFolderForm = ({
44
+ parentLabel,
45
+ name,
46
+ onNameChange,
47
+ onSubmit,
48
+ onCancel,
49
+ pending = false,
50
+ error,
51
+ labels,
52
+ }: NewFolderFormProps) => {
53
+ const text = { ...defaultNewFolderFormLabels, ...labels };
54
+ const fieldId = useId();
55
+ const nameRef = useRef<HTMLInputElement>(null);
56
+ const formRef = useRef<HTMLDivElement>(null);
57
+
58
+ /**
59
+ * The whole form is brought into view, buttons included — focusing the field
60
+ * would otherwise scroll to the field alone and leave Create below the fold,
61
+ * under a footer or a soft keyboard.
62
+ */
63
+ useEffect(() => {
64
+ nameRef.current?.focus({ preventScroll: true });
65
+ formRef.current?.scrollIntoView({ block: "nearest" });
66
+ }, []);
67
+
68
+ return (
69
+ <div
70
+ ref={formRef}
71
+ className="space-y-3 border-y border-line bg-surface-sunken px-3 py-3"
72
+ >
73
+ <div>
74
+ <FieldLabel htmlFor={fieldId}>{text.nameLabel}</FieldLabel>
75
+ <Input
76
+ id={fieldId}
77
+ ref={nameRef}
78
+ value={name}
79
+ placeholder={text.namePlaceholder}
80
+ onChange={(event) => onNameChange(event.target.value)}
81
+ onKeyDown={(event) => {
82
+ if (event.key === "Enter") {
83
+ event.preventDefault();
84
+ onSubmit();
85
+ }
86
+ if (event.key === "Escape") {
87
+ event.preventDefault();
88
+ onCancel();
89
+ }
90
+ }}
91
+ />
92
+ </div>
93
+ <p className="text-xs text-fg-muted">
94
+ {text.insideLabel}{" "}
95
+ <span className="font-medium text-fg">{parentLabel}</span>
96
+ </p>
97
+ {error && (
98
+ <p className="text-xs text-danger" role="alert">
99
+ {error}
100
+ </p>
101
+ )}
102
+ {/* Sized to their labels rather than to the pane: a full-width primary
103
+ here reads as loudly as the wizard's Continue below it, and making a
104
+ folder is the smaller commitment of the two. */}
105
+ <div className="flex items-center gap-2">
106
+ <Button
107
+ variant="ghost"
108
+ size="md"
109
+ onClick={onCancel}
110
+ className="min-h-11 shrink-0"
111
+ >
112
+ {text.cancel}
113
+ </Button>
114
+ <Button
115
+ variant="secondary"
116
+ size="md"
117
+ onClick={onSubmit}
118
+ disabled={pending}
119
+ className="min-h-11 shrink-0"
120
+ >
121
+ {pending ? text.createPending : text.create}
122
+ </Button>
123
+ </div>
124
+ </div>
125
+ );
126
+ };
package/src/index.ts CHANGED
@@ -198,6 +198,20 @@ export {
198
198
  type FilterSheetProps,
199
199
  type FilterSheetSource,
200
200
  } from "./components/filter-sheet.js";
201
+ export {
202
+ FolderManageActions,
203
+ type FolderManageActionsProps,
204
+ } from "./components/folder-manage-actions.js";
205
+ export {
206
+ FolderManager,
207
+ type FolderManagerLabels,
208
+ type FolderManagerProps,
209
+ type ManagedFolder,
210
+ } from "./components/folder-manager.js";
211
+ export {
212
+ FolderRenameDialog,
213
+ type FolderRenameDialogProps,
214
+ } from "./components/folder-rename-dialog.js";
201
215
  export {
202
216
  canonicalRoleLabel,
203
217
  type FolderRole,
@@ -207,6 +221,10 @@ export {
207
221
  type ResultFolder,
208
222
  roleIcon,
209
223
  } from "./components/folder-role.js";
224
+ export {
225
+ FolderRow,
226
+ type FolderRowProps,
227
+ } from "./components/folder-row.js";
210
228
  export {
211
229
  type FolderTreeNode,
212
230
  FolderTreePicker,
@@ -322,6 +340,16 @@ export {
322
340
  NavSidebar,
323
341
  type NavSidebarProps,
324
342
  } from "./components/nav-sidebar.js";
343
+ export {
344
+ NewFolderAction,
345
+ type NewFolderActionProps,
346
+ type NewFolderProminence,
347
+ } from "./components/new-folder-action.js";
348
+ export {
349
+ NewFolderForm,
350
+ type NewFolderFormLabels,
351
+ type NewFolderFormProps,
352
+ } from "./components/new-folder-form.js";
325
353
  export { OutboxRow, type OutboxRowProps } from "./components/outbox-row.js";
326
354
  export {
327
355
  type OutboxStatus,
@@ -619,6 +647,25 @@ export {
619
647
  sanitizeInlineStyle,
620
648
  sanitizeStyleElementCss,
621
649
  } from "./lib/email-sanitizer.js";
650
+ export {
651
+ collapseFolderTree,
652
+ filterFolderTree,
653
+ folderAncestors,
654
+ folderDepth,
655
+ folderParent,
656
+ matchesQuery,
657
+ orderFolderNodes,
658
+ queryExpandedPaths,
659
+ withCreateRows,
660
+ } from "./lib/folder-tree.js";
661
+ export {
662
+ findFirstFocusable,
663
+ findLastFocusable,
664
+ findNextFocusable,
665
+ findParentRow,
666
+ isFocusable,
667
+ isSelectable,
668
+ } from "./lib/folder-tree-focus.js";
622
669
  export {
623
670
  isLabelColorValue,
624
671
  type LabelColorValue,
@@ -0,0 +1,98 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ collapseFolderTree,
5
+ type FolderTreeNode,
6
+ filterFolderTree,
7
+ orderFolderNodes,
8
+ } from "./folder-tree.js";
9
+ import {
10
+ findFirstFocusable,
11
+ findLastFocusable,
12
+ findNextFocusable,
13
+ findParentRow,
14
+ isSelectable,
15
+ } from "./folder-tree-focus.js";
16
+
17
+ const node = (
18
+ id: string,
19
+ label: string,
20
+ path: string,
21
+ isCurrent?: boolean,
22
+ ): FolderTreeNode => ({ id, label, path, isCurrent });
23
+
24
+ const ordered = orderFolderNodes(
25
+ [
26
+ node("inbox", "Inbox", "INBOX", true),
27
+ node("hotels", "Hotels", "Travel/Hotels"),
28
+ node("archive", "Archive", "Archive"),
29
+ node("travel", "Travel", "Travel"),
30
+ node("receipts", "Receipts", "Travel/Hotels/Receipts"),
31
+ node("trash", "Trash", "Deleted Messages"),
32
+ ],
33
+ "/",
34
+ );
35
+
36
+ describe("roving focus", () => {
37
+ const rows = filterFolderTree(ordered, "receipts", "/");
38
+
39
+ it("skips context rows, which are branches rather than folders to open", () => {
40
+ assert.equal(findFirstFocusable(rows), 2);
41
+ assert.equal(findLastFocusable(rows), 2);
42
+ });
43
+
44
+ it("reaches the current folder, which cannot be picked but can be opened", () => {
45
+ const all = collapseFolderTree(ordered, new Set(), "/");
46
+ assert.equal(findFirstFocusable(all), 0);
47
+ assert.equal(findNextFocusable(all, 3, 1), 0);
48
+ assert.equal(findNextFocusable(all, 0, -1), 3);
49
+ });
50
+
51
+ it("walks only what is on screen, so closed children are skipped", () => {
52
+ const closed = collapseFolderTree(ordered, new Set(), "/");
53
+ assert.equal(
54
+ closed[findNextFocusable(closed, 2, 1)]?.folder.path,
55
+ "Deleted Messages",
56
+ );
57
+ const open = collapseFolderTree(ordered, new Set(["Travel"]), "/");
58
+ assert.equal(
59
+ open[findNextFocusable(open, 2, 1)]?.folder.path,
60
+ "Travel/Hotels",
61
+ );
62
+ });
63
+
64
+ it("walks from nowhere into the list from either end", () => {
65
+ const all = collapseFolderTree(ordered, new Set(), "/");
66
+ assert.equal(findNextFocusable(all, -1, 1), 0);
67
+ assert.equal(findNextFocusable(all, -1, -1), 3);
68
+ });
69
+
70
+ it("finds the parent to move focus to when a row closes", () => {
71
+ const open = collapseFolderTree(ordered, new Set(["Travel"]), "/");
72
+ assert.equal(findParentRow(open, 3, "/"), 2);
73
+ assert.equal(findParentRow(open, 2, "/"), -1);
74
+ });
75
+
76
+ it("finds no parent for a row that is not on screen", () => {
77
+ const open = collapseFolderTree(ordered, new Set(["Travel"]), "/");
78
+ assert.equal(findParentRow(open, 99, "/"), -1);
79
+ assert.equal(
80
+ findParentRow(filterFolderTree(ordered, "hotels", "/"), 1, "/"),
81
+ -1,
82
+ );
83
+ });
84
+
85
+ it("returns -1 when there is nothing to walk", () => {
86
+ assert.equal(findFirstFocusable([]), -1);
87
+ assert.equal(findLastFocusable([]), -1);
88
+ assert.equal(findNextFocusable([], 0, 1), -1);
89
+ });
90
+
91
+ it("keeps the current folder and a context branch out of the selection", () => {
92
+ const all = collapseFolderTree(ordered, new Set(), "/");
93
+ assert.equal(isSelectable(all[0]), false);
94
+ assert.equal(isSelectable(all[1]), true);
95
+ assert.equal(isSelectable(rows[0]), false);
96
+ assert.equal(isSelectable(undefined), false);
97
+ });
98
+ });
@@ -0,0 +1,52 @@
1
+ import { type FolderTreeRow, folderParent } from "./folder-tree.js";
2
+
3
+ /** Every folder can hold a new one, so every row but a context row can open. */
4
+ export const isFocusable = (row: FolderTreeRow | undefined): boolean =>
5
+ row !== undefined && !row.context;
6
+
7
+ export const isSelectable = (row: FolderTreeRow | undefined): boolean =>
8
+ row !== undefined && !row.folder.isCurrent && !row.context;
9
+
10
+ export const findFirstFocusable = (rows: readonly FolderTreeRow[]): number => {
11
+ for (let i = 0; i < rows.length; i += 1) {
12
+ if (isFocusable(rows[i])) return i;
13
+ }
14
+ return -1;
15
+ };
16
+
17
+ export const findLastFocusable = (rows: readonly FolderTreeRow[]): number => {
18
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
19
+ if (isFocusable(rows[i])) return i;
20
+ }
21
+ return -1;
22
+ };
23
+
24
+ export const findNextFocusable = (
25
+ rows: readonly FolderTreeRow[],
26
+ from: number,
27
+ step: 1 | -1,
28
+ ): number => {
29
+ const count = rows.length;
30
+ if (count <= 0) return -1;
31
+ const start = from < 0 ? (step === 1 ? -1 : count) : from;
32
+ for (let offset = 1; offset <= count; offset += 1) {
33
+ const candidate = (((start + step * offset) % count) + count) % count;
34
+ if (isFocusable(rows[candidate])) return candidate;
35
+ }
36
+ return -1;
37
+ };
38
+
39
+ export const findParentRow = (
40
+ rows: readonly FolderTreeRow[],
41
+ from: number,
42
+ delimiter: string,
43
+ ): number => {
44
+ const child = rows[from];
45
+ if (!child) return -1;
46
+ const parent = folderParent(child.folder.path, delimiter);
47
+ if (!parent) return -1;
48
+ for (let i = from - 1; i >= 0; i -= 1) {
49
+ if (rows[i]?.folder.path === parent) return isFocusable(rows[i]) ? i : -1;
50
+ }
51
+ return -1;
52
+ };