Skip to content
Source
Sign inSign up

sidebarTree.browser.test.ts

188 lines6.9 KB

1import { describe, expect, it, vi } from "vite-plus/test";
2import { userEvent } from "vite-plus/test/browser";
3import { installSidebarTree } from "./sidebarTree";
4​
5function setup() {
6 const root = document.createElement("div");
7 document.body.append(root);
8 let dispose: (() => void) | undefined;
9 const row = (id: string, parent?: string) => {
10 const element = document.createElement("button");
11 element.dataset.sidebarRow = id;
12 if (parent) element.dataset.sidebarParent = parent;
13 element.textContent = id;
14 root.append(element);
15 return element;
16 };
17 return {
18 root,
19 row,
20 install: () => {
21 dispose = installSidebarTree(root);
22 },
23 [Symbol.dispose]() {
24 dispose?.();
25 root.remove();
26 },
27 };
29​
30function press(target: HTMLElement, key: string, init: KeyboardEventInit = {}) {
31 const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
32 target.dispatchEvent(event);
33 return event;
35​
36describe("sidebar keyboard navigation", () => {
37 it("moves focus without activating rows, clamps at the ends, and keeps every row in the Tab order", () => {
38 using tree = setup();
39 const first = tree.row("first");
40 const second = tree.row("second");
41 const last = tree.row("last");
42 const activate = vi.fn();
43 tree.root.addEventListener("click", activate);
44 tree.install();
45 first.focus();
46 press(first, "ArrowDown");
47 expect(document.activeElement).toBe(second);
48 press(second, "End");
49 expect(document.activeElement).toBe(last);
50 press(last, "ArrowDown");
51 expect(document.activeElement).toBe(last);
52 press(last, "Home");
53 expect(document.activeElement).toBe(first);
54 expect(activate).not.toHaveBeenCalled();
55 expect([first.tabIndex, second.tabIndex, last.tabIndex]).toEqual([0, 0, 0]);
56 });
57​
58 it("expands a group before entering its first child and returns to the parent", () => {
59 using tree = setup();
60 const group = tree.row("project");
61 group.setAttribute("aria-expanded", "false");
62 group.addEventListener("click", () => {
63 const expanded = group.getAttribute("aria-expanded") === "true";
64 group.setAttribute("aria-expanded", String(!expanded));
65 if (expanded) tree.root.querySelector('[data-sidebar-row="thread"]')?.remove();
66 else tree.row("thread", "project");
67 });
68 tree.install();
69 group.focus();
70 press(group, "ArrowRight");
71 expect(group.getAttribute("aria-expanded")).toBe("true");
72 expect(document.activeElement).toBe(group);
73 press(group, "ArrowRight");
74 const child = document.activeElement as HTMLElement;
75 expect(child.dataset.sidebarRow).toBe("thread");
76 press(child, "ArrowLeft");
77 expect(document.activeElement).toBe(group);
78 press(group, "ArrowLeft");
79 expect(group.getAttribute("aria-expanded")).toBe("false");
80 });
81​
82 it("opens the row menu on Shift+F10 release without moving focus", () => {
83 using tree = setup();
84 tree.row("first");
85 const selected = tree.row("selected");
86 const contextMenu = vi.fn((event: MouseEvent) => event.preventDefault());
87 selected.addEventListener("contextmenu", contextMenu);
88 tree.install();
89 selected.focus();
90 expect(press(selected, "F10", { shiftKey: true }).defaultPrevented).toBe(true);
91 expect(contextMenu).not.toHaveBeenCalled();
92 selected.dispatchEvent(
93 new KeyboardEvent("keyup", { key: "F10", shiftKey: true, bubbles: true, cancelable: true }),
94 );
95 expect(contextMenu).toHaveBeenCalledOnce();
96 expect(document.activeElement).toBe(selected);
97 });
98​
99 it("leaves every row's buttons in the Tab order", () => {
100 using tree = setup();
101 const first = tree.row("first");
102 const second = tree.row("second");
103 const action = (owner: string) => {
104 const button = document.createElement("button");
105 button.textContent = `Archive ${owner}`;
106 button.dataset.sidebarActionFor = owner;
107 tree.root.append(button);
108 return button;
109 };
110 const firstAction = action("first");
111 const secondAction = action("second");
112 tree.install();
113 first.focus();
114 expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, 0]);
115 press(first, "ArrowDown");
116 expect(document.activeElement).toBe(second);
117 expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([0, 0]);
118 });
119​
120 it("keeps a rendered row under a collapsed group reachable", () => {
121 using tree = setup();
122 const shelf = tree.row("shelf:settled");
123 shelf.setAttribute("aria-expanded", "false");
124 // A collapsed shelf still renders the open thread.
125 const open = tree.row("thread", "shelf:settled");
126 tree.install();
127 shelf.focus();
128 press(shelf, "ArrowDown");
129 expect(document.activeElement).toBe(open);
130 });
131​
132 it("skips disabled rows and leaves controls outside rows in the Tab order", () => {
133 using tree = setup();
134 const first = tree.row("first");
135 const loading = tree.row("settled:more");
136 loading.disabled = true;
137 const undo = document.createElement("button");
138 undo.textContent = "Undo";
139 tree.root.append(undo);
140 tree.install();
141 first.focus();
142 press(first, "ArrowDown");
143 expect(document.activeElement).toBe(first);
144 expect(undo.tabIndex).toBe(0);
145 });
146​
147 it("keeps the scroll position when focus follows a row that moved away", async () => {
148 using tree = setup();
149 Object.assign(tree.root.style, { height: "100px", overflow: "auto" });
150 const settled = tree.row("settled");
151 const settle = document.createElement("button");
152 settle.textContent = "Settle";
153 settled.after(settle);
154 settle.dataset.sidebarActionFor = "settled";
155 for (let index = 0; index < 20; index++) tree.row(`row-${index}`).style.display = "block";
156 tree.install();
157 settle.focus();
158 // Settling moves the row to the shelf at the bottom and removes the focused button.
159 settle.remove();
160 settled.remove();
161 const moved = tree.row("settled");
162 await vi.waitFor(() => expect(document.activeElement).toBe(moved));
163 expect(tree.root.scrollTop).toBe(0);
164 });
165​
166 it("does not pull focus back after a click outside the rows", async () => {
167 using tree = setup();
168 const first = tree.row("first");
169 tree.install();
170 const text = document.createElement("p");
171 text.textContent = "Chat text";
172 document.body.append(text);
173 using _text = { [Symbol.dispose]: () => text.remove() };
174 const spacer = document.createElement("div");
175 spacer.textContent = "Empty list space";
176 tree.root.append(spacer);
177 for (const target of [text, spacer]) {
178 first.focus();
179 await userEvent.click(target);
180 expect(document.activeElement).toBe(document.body);
181 // A status change re-renders the list while the user works elsewhere.
182 tree.row(`second-${target.tagName}`);
183 // The list's mutation observer callback is queued ahead of this continuation.
184 await Promise.resolve();
185 expect(document.activeElement).toBe(document.body);
186 }
187 });
188});