1const ROW = "[data-sidebar-row]";
2
3/** Ref callback for a sidebar list; installs its keyboard navigation and returns its cleanup. */
4export function attachSidebarTree(element: HTMLElement | null) {
5 return element ? installSidebarTree(element) : undefined;
6}
7
8/**
9 * Arrow keys move focus between rows without opening them, as in VS Code's list commands.
10 * Rows stay ordinary buttons in the Tab order so screen readers can browse the list.
11 */
12export function installSidebarTree(root: HTMLElement) {
13 let focusedId: string | undefined;
14 let focusedParent: string | undefined;
15 let focusedIndex = -1;
16 let ownsFocus = false;
17 let contextMenuRow: HTMLElement | undefined;
18
19 const isVisibleRow = (row: HTMLElement) => {
20 const hidden = row.closest('[hidden], [inert], [aria-hidden="true"]');
21 // A dialog can temporarily make the whole workspace inert. Keep rows navigable
22 // for its return while still excluding exiting row clones.
23 return (!hidden || !root.contains(hidden)) && !row.matches(":disabled");
24 };
25 const visibleRows = () =>
26 Array.from(root.querySelectorAll<HTMLElement>(ROW)).filter(isVisibleRow);
27
28 const ownerRow = (element: HTMLElement) => {
29 const row = element.closest<HTMLElement>(ROW);
30 if (row) return row;
31 const linked = element.dataset.sidebarActionFor;
32 return linked
33 ? root.querySelector<HTMLElement>(`[data-sidebar-row="${CSS.escape(linked)}"]`)
34 : null;
35 };
36
37 const focusRow = (row: HTMLElement) => {
38 // Revealing before focusing also materializes content-visibility:auto descendants.
39 row.scrollIntoView({ block: "nearest" });
40 row.focus({ preventScroll: true });
41 };
42
43 // A row that moves shelves remounts and drops focus to the body; follow it.
44 const restoreFocus = () => {
45 if (!ownsFocus || focusedId === undefined) return;
46 const active = document.activeElement;
47 const lost =
48 active === document.body ||
49 (active instanceof HTMLElement &&
50 root.contains(active) &&
51 active.matches(ROW) &&
52 !isVisibleRow(active));
53 if (!lost) return;
54 const rows = visibleRows();
55 const target =
56 rows.find((row) => row.dataset.sidebarRow === focusedId) ??
57 rows.find((row) => row.dataset.sidebarRow === focusedParent) ??
58 rows[Math.min(Math.max(focusedIndex, 0), rows.length - 1)];
59 // A settled row moves to the bottom shelf; scrolling after it fights the route reveal.
60 target?.focus({ preventScroll: true });
61 };
62
63 // Clicking non-focusable content, including empty list space, drops focus to the body
64 // without a focusin event. A click on the focused row fires no focusin either.
65 const onPointerDown = (event: PointerEvent) => {
66 const focusable =
67 event.target instanceof Element
68 ? event.target.closest(`${ROW}, button, a[href], input, [tabindex]`)
69 : null;
70 ownsFocus = focusable !== null && root.contains(focusable);
71 };
72 const onFocus = (event: FocusEvent) => {
73 ownsFocus = root.contains(event.target as Node);
74 if (!ownsFocus || !(event.target instanceof HTMLElement)) return;
75 const row = ownerRow(event.target);
76 if (!row) return;
77 focusedId = row.dataset.sidebarRow;
78 focusedParent = row.dataset.sidebarParent;
79 focusedIndex = visibleRows().indexOf(row);
80 };
81 const onKeyDown = (event: KeyboardEvent) => {
82 const row = event.target;
83 if (
84 !event.defaultPrevented &&
85 !event.isComposing &&
86 !event.altKey &&
87 !event.ctrlKey &&
88 !event.metaKey &&
89 (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) &&
90 row instanceof HTMLElement &&
91 row.matches(ROW)
92 ) {
93 event.preventDefault();
94 event.stopPropagation();
95 contextMenuRow = row;
96 return;
97 }
98 if (
99 event.defaultPrevented ||
100 event.isComposing ||
101 event.altKey ||
102 event.ctrlKey ||
103 event.metaKey ||
104 event.shiftKey
105 )
106 return;
107 if (!(row instanceof HTMLElement) || !row.matches(ROW)) return;
108 const rows = visibleRows();
109 const index = rows.indexOf(row);
110 if (index < 0) return;
111 let target: HTMLElement | undefined;
112 switch (event.key) {
113 case "ArrowDown":
114 target = rows[Math.min(index + 1, rows.length - 1)];
115 break;
116 case "ArrowUp":
117 target = rows[Math.max(index - 1, 0)];
118 break;
119 case "Home":
120 target = rows[0];
121 break;
122 case "End":
123 target = rows.at(-1);
124 break;
125 case "ArrowRight":
126 if (row.getAttribute("aria-expanded") === "false") row.click();
127 else
128 target = rows.find(
129 (candidate) => candidate.dataset.sidebarParent === row.dataset.sidebarRow,
130 );
131 break;
132 case "ArrowLeft":
133 if (row.getAttribute("aria-expanded") === "true") row.click();
134 else
135 target = rows.find(
136 (candidate) => candidate.dataset.sidebarRow === row.dataset.sidebarParent,
137 );
138 break;
139 // Row handlers own Enter, Space, F2 rename, and the context menu.
140 default:
141 return;
142 }
143 event.preventDefault();
144 event.stopPropagation();
145 if (target) focusRow(target);
146 };
147 const onKeyUp = (event: KeyboardEvent) => {
148 if (!contextMenuRow || (event.key !== "ContextMenu" && event.key !== "F10")) return;
149 const row = contextMenuRow;
150 contextMenuRow = undefined;
151 event.preventDefault();
152 event.stopPropagation();
153 if (document.activeElement !== row || !row.isConnected) return;
154 const rect = row.getBoundingClientRect();
155 // Like VS Code's list, suppress keydown and open on release. Reuse the row's
156 // selection-aware handler, including its Electron native menu route.
157 row.dispatchEvent(
158 new MouseEvent("contextmenu", {
159 bubbles: true,
160 cancelable: true,
161 clientX: rect.left,
162 clientY: rect.bottom,
163 }),
164 );
165 };
166 const observer = new MutationObserver(restoreFocus);
167 observer.observe(root, {
168 childList: true,
169 subtree: true,
170 attributes: true,
171 attributeFilter: ["hidden", "inert", "aria-hidden"],
172 });
173 document.addEventListener("focusin", onFocus);
174 document.addEventListener("pointerdown", onPointerDown, true);
175 root.addEventListener("keydown", onKeyDown, true);
176 root.addEventListener("keyup", onKeyUp, true);
177 return () => {
178 observer.disconnect();
179 document.removeEventListener("focusin", onFocus);
180 document.removeEventListener("pointerdown", onPointerDown, true);
181 root.removeEventListener("keydown", onKeyDown, true);
182 root.removeEventListener("keyup", onKeyUp, true);
183 };
184}