diff --git a/CHANGELOG.md b/CHANGELOG.md
index 079e841..da56895 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0
- Traverse the article navigator with the arrow keys, `Home`, and `End`, and pass it with a single `Tab` instead of one per article.
- Jump to an article by typing the start of its name while the navigator has focus.
- Leave focus on the revealed row after `Reveal in sidebar`, instead of scrolling to it and leaving focus behind.
+- Keep focus in the article navigator when a folder refresh removes the focused row, instead of dropping it to the start of the window.
- Announce the article navigator as a tree, with the nesting depth, sibling position, and expanded state of every row.
- Keep empty folders in the article navigator reachable instead of skipping them.
- Open the editor context popup with `Shift+F10` or the `Menu` key and operate every command in it from the keyboard.
diff --git a/docs/specification.md b/docs/specification.md
index a89588c..7094a13 100644
--- a/docs/specification.md
+++ b/docs/specification.md
@@ -52,7 +52,7 @@ Primary user interface surfaces:
### Article Navigator Traversal
-The article navigator is a tree and takes a single tab stop. Focus enters on the open document, or on the first row when no document is open.
+The article navigator is a tree and takes a single tab stop. Focus enters on the open document, or on the first row when no document is open. When a folder refresh removes the focused row, focus moves to the row that inherits the tab stop.
- `ArrowDown` and `ArrowUp`: Move to the next or previous visible row, stopping at either end.
- `ArrowRight`: Expand the focused directory, or move into it when it is already expanded.
diff --git a/src/features/folder-context/components/article-navigator.test.tsx b/src/features/folder-context/components/article-navigator.test.tsx
index 2f7472e..3c5d655 100644
--- a/src/features/folder-context/components/article-navigator.test.tsx
+++ b/src/features/folder-context/components/article-navigator.test.tsx
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { FolderContextState } from "@/features/folder-context";
import {
createEmptyFolderContext,
createFolderContext,
@@ -16,6 +17,14 @@ const folderContext = createFolderContext();
const nestedFolderContext = createFolderContext({ tree: createNestedArticleTree() });
+const nestedFolderContextWithoutSpec = createFolderContext({
+ tree: createNestedArticleTree({
+ children: createNestedArticleTree().children.map((child) =>
+ child.kind === "directory" ? { ...child, children: [] } : child,
+ ),
+ }),
+});
+
const emptyFolderContext = createEmptyFolderContext();
const folderContextWithScanWarning = createFolderContext({
@@ -250,6 +259,78 @@ describe("article-navigator", () => {
expect(screen.getAllByRole("treeitem").filter((row) => row.tabIndex === 0)).toHaveLength(1);
});
+ it("follows the tab stop when a rebuild removes the focused row", () => {
+ useArticleNavigatorStore.getState().expandDirectories([TEST_NESTED_DIRECTORY_PATH]);
+
+ const { rerender } = render(
+ ,
+ );
+
+ act(() => screen.getByRole("treeitem", { name: "spec.md" }).focus());
+ rerender(
+ ,
+ );
+
+ const directory = screen.getByRole("treeitem", { name: "docs" });
+
+ expect(directory).toHaveFocus();
+ expect(directory.tabIndex).toBe(0);
+ });
+
+ it("leaves focus outside the navigator when a rebuild removes a row", () => {
+ useArticleNavigatorStore.getState().expandDirectories([TEST_NESTED_DIRECTORY_PATH]);
+
+ const renderTree = (folderContext: FolderContextState) => (
+ <>
+
+
+ >
+ );
+ const { rerender } = render(renderTree(nestedFolderContext));
+
+ act(() => screen.getByRole("treeitem", { name: "spec.md" }).focus());
+ act(() => screen.getByRole("button", { name: "Editor" }).focus());
+ rerender(renderTree(nestedFolderContextWithoutSpec));
+
+ expect(screen.getByRole("button", { name: "Editor" })).toHaveFocus();
+ });
+
+ it("does not claim focus from the document body when a rebuild removes a row", () => {
+ useArticleNavigatorStore.getState().expandDirectories([TEST_NESTED_DIRECTORY_PATH]);
+
+ const { rerender } = render(
+ ,
+ );
+
+ act(() => screen.getByRole("treeitem", { name: "spec.md" }).focus());
+ act(() => screen.getByRole("treeitem", { name: "spec.md" }).blur());
+ rerender(
+ ,
+ );
+
+ expect(document.body).toHaveFocus();
+ });
+
it("focuses the revealed row and hands it the tab stop", () => {
render(
state.revealRequestId);
const [focus, setFocus] = useState({ path: null, requestId: 0 });
const rowElementsRef = useRef(new Map());
+ const hasRowFocusRef = useRef(false);
const typeaheadRef = useRef({ buffer: "", lastKeyAtMs: 0 });
const focusedIndex = getArticleNavigatorFocusedIndex(rows, focus.path);
+ const focusedRowPath = rows[focusedIndex]?.path;
const handledRevealRequestIdRef = useRef(0);
const revealRowIndex = rows.findIndex(
(row) =>
@@ -236,6 +238,18 @@ function ArticleNavigatorRows({
rowElementsRef.current.get(revealRowPath)?.focus();
}, [revealRequestId, revealRowIndex, revealRowPath]);
+ useEffect(() => {
+ if (
+ focusedRowPath === undefined ||
+ !hasRowFocusRef.current ||
+ document.activeElement !== document.body
+ ) {
+ return;
+ }
+
+ rowElementsRef.current.get(focusedRowPath)?.focus();
+ }, [focusedRowPath]);
+
const focusRow = (index: number) => {
const path = rows[index]?.path;
@@ -323,6 +337,9 @@ function ArticleNavigatorRows({
{
+ hasRowFocusRef.current = false;
+ }}
onKeyDown={handleKeyDown}
role="tree"
>
@@ -332,7 +349,10 @@ function ArticleNavigatorRows({
key={row.path}
isTabStop={index === focusedIndex}
onActivate={() => activateRow(index)}
- onFocus={() => setFocus((currentFocus) => ({ ...currentFocus, path: row.path }))}
+ onFocus={() => {
+ hasRowFocusRef.current = true;
+ setFocus((currentFocus) => ({ ...currentFocus, path: row.path }));
+ }}
registerElement={(element) =>
registerRowElement(rowElementsRef.current, row, element)
}