From 3f8cdb4013934b744a4e793b6977473b616021eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Mon, 10 Aug 2026 03:13:29 -0300 Subject: [PATCH] Restore article navigator focus after a rebuild drops the focused row --- CHANGELOG.md | 1 + docs/specification.md | 2 +- .../components/article-navigator.test.tsx | 81 +++++++++++++++++++ .../components/article-navigator.tsx | 22 ++++- 4 files changed, 104 insertions(+), 2 deletions(-) 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) }