Go to a folder by name, with g then o
Requested in #233. The `g` shortcuts cover the handful of folders every account has -- inbox, sent, drafts -- and nothing reaches the dozens a Sieve rule fills, which is where somebody with a real folder tree spends their time. `g o` opens the picker, you type part of a name, and you are there. The picker is the one the move action already uses, with one difference that only shows up on shared mail: it selected folders by `mayAddItems`, which is right for a destination and wrong for a place to go. A shared folder you may read but not file into is somewhere you can visit. The right is now a parameter, named for what it is asking rather than for which caller wants it. Hosted in AppShell rather than in the mail view, because the `g` shortcuts are global and the mail view is not mounted to hear about it -- pressing this from the calendar should still take you to a folder, and now does. `o` on its own opens a conversation and does not clash: a pending prefix is tried before a bare key. That was already true and nothing said so, so there are now five tests for the sequence machinery -- including that an abandoned prefix costs the prefix and not the keystroke after it, which is the nicer behaviour of the two and was undocumented. Checked in a browser against the mock: opened from the calendar, filtered to a nested folder, landed on it, and `o` still opened a conversation afterwards. Closes #233.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
|
||||
/*
|
||||
* Two-key sequences against the single keys they start with.
|
||||
*
|
||||
* "Go to folder" is `g o` while `o` on its own opens a conversation (#233), so
|
||||
* the whole feature rests on a pending prefix being tried before a bare key.
|
||||
* That was true when it was written and nothing said so out loud, which is the
|
||||
* kind of thing a later refactor quietly reverses.
|
||||
*/
|
||||
|
||||
const press = (key: string) => {
|
||||
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
window.dispatchEvent(e);
|
||||
return e;
|
||||
};
|
||||
|
||||
let pop: (() => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
pop?.();
|
||||
pop = null;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("a sequence sharing its second key with a single binding", () => {
|
||||
it("runs the sequence, not the single key", () => {
|
||||
const seq = vi.fn();
|
||||
const single = vi.fn();
|
||||
pop = keyboard.pushScope("t", [
|
||||
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
|
||||
{ keys: "o", description: "Open", group: "Mail", handler: single },
|
||||
]);
|
||||
press("g");
|
||||
press("o");
|
||||
expect(seq).toHaveBeenCalledOnce();
|
||||
expect(single).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the single key when no prefix is pending", () => {
|
||||
const seq = vi.fn();
|
||||
const single = vi.fn();
|
||||
pop = keyboard.pushScope("t", [
|
||||
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
|
||||
{ keys: "o", description: "Open", group: "Mail", handler: single },
|
||||
]);
|
||||
press("o");
|
||||
expect(single).toHaveBeenCalledOnce();
|
||||
expect(seq).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forgets the prefix after a pause, so a later key means itself again", () => {
|
||||
const seq = vi.fn();
|
||||
const single = vi.fn();
|
||||
pop = keyboard.pushScope("t", [
|
||||
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
|
||||
{ keys: "o", description: "Open", group: "Mail", handler: single },
|
||||
]);
|
||||
press("g");
|
||||
vi.advanceTimersByTime(2000);
|
||||
press("o");
|
||||
expect(seq).not.toHaveBeenCalled();
|
||||
expect(single).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("swallows the prefix rather than letting it act on its own", () => {
|
||||
// `g` is not a binding by itself; pressing it must not fall through to
|
||||
// anything, or holding it would type into the page.
|
||||
const seq = vi.fn();
|
||||
pop = keyboard.pushScope("t", [
|
||||
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
|
||||
]);
|
||||
const e = press("g");
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
expect(seq).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a key that completes no sequence still act as itself", () => {
|
||||
/*
|
||||
* `g` then `z`, where `g z` is nothing. The prefix is dropped and `z` runs
|
||||
* on that same press rather than being eaten — so a mistyped prefix costs
|
||||
* the prefix and not the keystroke after it.
|
||||
*/
|
||||
const seq = vi.fn();
|
||||
const single = vi.fn();
|
||||
pop = keyboard.pushScope("t", [
|
||||
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
|
||||
{ keys: "z", description: "Zed", group: "Mail", handler: single },
|
||||
]);
|
||||
press("g");
|
||||
press("z");
|
||||
expect(seq).not.toHaveBeenCalled();
|
||||
expect(single).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user