Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
81 changes: 81 additions & 0 deletions src/features/folder-context/components/article-navigator.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { FolderContextState } from "@/features/folder-context";
import {
createEmptyFolderContext,
createFolderContext,
Expand All @@ -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({
Expand Down Expand Up @@ -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(
<ArticleNavigator
activeArticlePath={null}
folderContext={nestedFolderContext}
onOpenArticle={vi.fn()}
/>,
);

act(() => screen.getByRole("treeitem", { name: "spec.md" }).focus());
rerender(
<ArticleNavigator
activeArticlePath={null}
folderContext={nestedFolderContextWithoutSpec}
onOpenArticle={vi.fn()}
/>,
);

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) => (
<>
<button type="button">Editor</button>
<ArticleNavigator
activeArticlePath={null}
folderContext={folderContext}
onOpenArticle={vi.fn()}
/>
</>
);
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(
<ArticleNavigator
activeArticlePath={null}
folderContext={nestedFolderContext}
onOpenArticle={vi.fn()}
/>,
);

act(() => screen.getByRole("treeitem", { name: "spec.md" }).focus());
act(() => screen.getByRole("treeitem", { name: "spec.md" }).blur());
rerender(
<ArticleNavigator
activeArticlePath={null}
folderContext={nestedFolderContextWithoutSpec}
onOpenArticle={vi.fn()}
/>,
);

expect(document.body).toHaveFocus();
});

it("focuses the revealed row and hands it the tab stop", () => {
render(
<ArticleNavigator
Expand Down
22 changes: 21 additions & 1 deletion src/features/folder-context/components/article-navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,10 @@ function ArticleNavigatorRows({
const revealRequestId = useArticleNavigatorStore((state) => state.revealRequestId);
const [focus, setFocus] = useState<ArticleNavigatorFocus>({ path: null, requestId: 0 });
const rowElementsRef = useRef(new Map<string, HTMLLIElement>());
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) =>
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -323,6 +337,9 @@ function ArticleNavigatorRows({
<VirtualListContent
aria-label="Articles"
className="mx-1"
onBlur={() => {
hasRowFocusRef.current = false;
}}
onKeyDown={handleKeyDown}
role="tree"
>
Expand All @@ -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)
}
Expand Down