@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
234 lines (181 loc) • 6.9 kB
text/typescript
import { describe, it, expect, vi } from "vitest";
import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
import type {
Unstable_TriggerCategory,
Unstable_TriggerItem,
} from "@assistant-ui/core";
import { TriggerKeyboardResource } from "./triggerKeyboardResource";
const item = (id: string): Unstable_TriggerItem => ({
id,
type: "command",
label: id,
});
const category = (id: string): Unstable_TriggerCategory => ({
id,
label: id,
});
const makeKeyEvent = (key: string, shiftKey = false) => ({
key,
shiftKey,
preventDefault: vi.fn(),
});
const render = (
overrides: Partial<Parameters<typeof TriggerKeyboardResource>[0]> = {},
) => {
const props = {
navigableList: [item("a"), item("b"), item("c")],
isSearchMode: false,
activeCategoryId: null as string | null,
query: "",
popoverId: "popover",
open: true,
selectItem: vi.fn(),
selectCategory: vi.fn(),
goBack: vi.fn(),
close: vi.fn(),
...overrides,
};
const root = createTapRoot(function Root() {
return useResource(TriggerKeyboardResource(props));
});
return { sub: root, props };
};
describe("TriggerKeyboardResource", () => {
it("selects highlighted item on Tab", () => {
const { sub, props } = render();
const e = makeKeyEvent("Tab");
const consumed = sub.getValue().handleKeyDown(e);
expect(consumed).toBe(true);
expect(e.preventDefault).toHaveBeenCalled();
expect(props.selectItem).toHaveBeenCalledWith(props.navigableList[0]);
expect(props.selectCategory).not.toHaveBeenCalled();
});
it("selects category on Tab when entry is a category", () => {
const { sub, props } = render({
navigableList: [category("cat-1"), item("b")],
});
const consumed = sub.getValue().handleKeyDown(makeKeyEvent("Tab"));
expect(consumed).toBe(true);
expect(props.selectCategory).toHaveBeenCalledWith("cat-1");
expect(props.selectItem).not.toHaveBeenCalled();
});
it("lets Shift+Tab pass through for native focus traversal", () => {
const { sub, props } = render();
const e = makeKeyEvent("Tab", true);
const consumed = sub.getValue().handleKeyDown(e);
expect(consumed).toBe(false);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(props.selectItem).not.toHaveBeenCalled();
});
it("swallows Tab when the navigable list is empty", () => {
const { sub, props } = render({ navigableList: [] });
const e = makeKeyEvent("Tab");
const consumed = sub.getValue().handleKeyDown(e);
expect(consumed).toBe(true);
expect(e.preventDefault).toHaveBeenCalled();
expect(props.selectItem).not.toHaveBeenCalled();
expect(props.selectCategory).not.toHaveBeenCalled();
});
it("does nothing when the popover is closed", () => {
const { sub, props } = render({ open: false });
const e = makeKeyEvent("Tab");
const consumed = sub.getValue().handleKeyDown(e);
expect(consumed).toBe(false);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(props.selectItem).not.toHaveBeenCalled();
});
it("moves the highlight forward on ArrowDown", () => {
const { sub } = render();
const consumed = flushTapSync(() =>
sub.getValue().handleKeyDown(makeKeyEvent("ArrowDown")),
);
expect(consumed).toBe(true);
expect(sub.getValue().highlightedIndex).toBe(1);
});
it("wraps the highlight to the top on ArrowDown past the last entry", () => {
const { sub } = render();
const handle = sub.getValue().handleKeyDown;
flushTapSync(() => {
handle(makeKeyEvent("ArrowDown"));
handle(makeKeyEvent("ArrowDown"));
handle(makeKeyEvent("ArrowDown"));
});
expect(sub.getValue().highlightedIndex).toBe(0);
});
it("moves the highlight backward on ArrowUp", () => {
const { sub } = render();
const handle = sub.getValue().handleKeyDown;
flushTapSync(() => handle(makeKeyEvent("ArrowDown")));
flushTapSync(() => handle(makeKeyEvent("ArrowUp")));
expect(sub.getValue().highlightedIndex).toBe(0);
});
it("wraps the highlight to the bottom on ArrowUp from the first entry", () => {
const { sub } = render();
flushTapSync(() => sub.getValue().handleKeyDown(makeKeyEvent("ArrowUp")));
expect(sub.getValue().highlightedIndex).toBe(2);
});
it("keeps the highlight at 0 on ArrowDown when navigableList is empty", () => {
const { sub } = render({ navigableList: [] });
const e = makeKeyEvent("ArrowDown");
const consumed = flushTapSync(() => sub.getValue().handleKeyDown(e));
expect(consumed).toBe(true);
expect(e.preventDefault).toHaveBeenCalled();
expect(sub.getValue().highlightedIndex).toBe(0);
});
it("selects the highlighted item on Enter", () => {
const { sub, props } = render();
const e = makeKeyEvent("Enter");
const consumed = sub.getValue().handleKeyDown(e);
expect(consumed).toBe(true);
expect(e.preventDefault).toHaveBeenCalled();
expect(props.selectItem).toHaveBeenCalledWith(props.navigableList[0]);
});
it("lets Shift+Enter pass through for newline insertion", () => {
const { sub, props } = render();
const e = makeKeyEvent("Enter", true);
const consumed = sub.getValue().handleKeyDown(e);
expect(consumed).toBe(false);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(props.selectItem).not.toHaveBeenCalled();
});
it("closes the popover on Escape", () => {
const { sub, props } = render();
const e = makeKeyEvent("Escape");
const consumed = sub.getValue().handleKeyDown(e);
expect(consumed).toBe(true);
expect(e.preventDefault).toHaveBeenCalled();
expect(props.close).toHaveBeenCalled();
});
it("drills back on Backspace when a category is active and the query is empty", () => {
const { sub, props } = render({
activeCategoryId: "cat-1",
query: "",
});
const e = makeKeyEvent("Backspace");
const consumed = sub.getValue().handleKeyDown(e);
expect(consumed).toBe(true);
expect(e.preventDefault).toHaveBeenCalled();
expect(props.goBack).toHaveBeenCalled();
});
it("lets Backspace pass through when the query is non-empty", () => {
const { sub, props } = render({
activeCategoryId: "cat-1",
query: "foo",
});
const e = makeKeyEvent("Backspace");
const consumed = sub.getValue().handleKeyDown(e);
expect(consumed).toBe(false);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(props.goBack).not.toHaveBeenCalled();
});
it("lets Backspace pass through when no category is active", () => {
const { sub, props } = render({
activeCategoryId: null,
query: "",
});
const e = makeKeyEvent("Backspace");
const consumed = sub.getValue().handleKeyDown(e);
expect(consumed).toBe(false);
expect(props.goBack).not.toHaveBeenCalled();
});
});