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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0

### Fixed

- 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.
- 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.
- Announce the editor context popup as a named toolbar instead of an unnamed dialog.
- Announce recent files and recent folders under their own headings in the `Open recent` menu.
Expand Down
15 changes: 15 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,21 @@
- Extending validation is a local change with no dependency surface, and its type-checking cost stays proportional to the shapes actually declared.
- The salvage and repair behavior is Leafdown's to maintain and test.

### Expose the article navigator as a flattened tree

**Decision:** The article navigator is an ARIA `tree`, flattened rather than nested: the scrolling list carries `role="tree"`, every row is a `treeitem` child of it, one row at a time holds the tab stop, and depth travels on `aria-level` with `role="group"` omitted. Selection does not follow focus — `aria-selected` marks the open document, and only `Enter`, `Space`, and click open one.

**Rationale:** Hierarchy has to be announced, not just indented, and a flat list of buttons has nowhere to put nesting, position, or expanded state. The nested `role="group"` markup the pattern usually shows cannot be produced here, because virtualization keeps only a window of rows in the DOM and a group wrapper would have to enclose children that do not exist; `aria-level` carries the same relationship without the DOM nesting. Selection following focus would open every document arrowed past, thrashing the editor. The tab stop roves across rows rather than resting on the container with `aria-activedescendant`: the active descendant still has to be a rendered row, so that model does not avoid keeping the focused row alive, and it gives up the native focus ring the rows already carry.

**Consequences:**

- `aria-setsize` and `aria-posinset` are scoped to siblings under the same parent and computed in the row model, because a flat row index answers a different question and the DOM holds only a window of rows.
- Every `treeitem` carries `aria-selected`, including directory rows that can never be selected. A tree where only some items carry it has the rest announced as "not selected".
- `aria-current` no longer marks the open document. The `data-active` visual treatment is unchanged.
- The focused row and the selected row are routinely different, which is what file-explorer users expect.
- Rows are `treeitem`s rather than buttons, so their keyboard behavior is the tree's to implement rather than something the platform supplies.
- The row holding the tab stop has to stay rendered even when it scrolls out of the virtualized window. Unmounting it drops focus to the document body and leaves the navigator with no tab stop at all, which would take the scroll region out of the tab sequence.

## Platform Decisions

### Windows first, cross-platform aware
Expand Down
15 changes: 15 additions & 0 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ Primary user interface surfaces:
- **Context popup:** provides quick document actions from selection or right-click.
- **Modal layer:** presents secondary screens and blocking dialogs outside the main editor surface.

### 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.

- `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.
- `ArrowLeft`: Collapse the focused directory, or move to the parent directory when it is already collapsed.
- `Home` and `End`: Move to the first or last visible row.
- Printable characters: Move to the next visible row whose name starts with what was typed, wrapping around. The search clears after a short pause, and one character repeated cycles through the rows that start with it.
- `Enter` and `Space`: Open the focused article, or expand and collapse the focused directory. Clicking a row does the same. `Space` extends a running search instead, since a space can appear in a file name.

Moving focus never opens a document, so the focused row and the open document are routinely different rows. An empty directory is an ordinary row that can be focused and read, with nothing to expand. Collapsing a directory that contains the focused row moves focus to the nearest row that survives.

`Reveal in sidebar` expands the ancestors of the active document, scrolls its row into view, and leaves focus on that row.

## State Model

These state axes compose. A document session, for example, can have a folder context and be saved and clean or dirty.
Expand Down
47 changes: 35 additions & 12 deletions src/components/layout/Shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,16 @@ describe("Shell", () => {

render(<Shell />);

expect(screen.getByRole("button", { name: "readme.md" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "draft.markdown" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "docs" })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("button", { name: "spec.md" })).not.toBeInTheDocument();
expect(screen.getByRole("treeitem", { name: "readme.md" })).toBeInTheDocument();
expect(screen.getByRole("treeitem", { name: "draft.markdown" })).toBeInTheDocument();
expect(screen.getByRole("treeitem", { name: "docs" })).toHaveAttribute(
"aria-expanded",
"false",
);
expect(screen.queryByRole("treeitem", { name: "spec.md" })).not.toBeInTheDocument();
expect(screen.getByRole("complementary", { name: "Article navigator" })).toContainElement(
screen.getByRole("tree", { name: "Articles" }),
);
expect(screen.getByText("No document open")).toBeInTheDocument();
expect(
screen.getByText("Select a Markdown file from the sidebar or create a new document."),
Expand All @@ -100,17 +106,34 @@ describe("Shell", () => {
render(<Shell />);

await waitFor(() => {
expect(screen.getByRole("button", { name: "spec.md" })).toHaveAttribute(
"aria-current",
"page",
expect(screen.getByRole("treeitem", { name: "spec.md" })).toHaveAttribute(
"aria-selected",
"true",
);
});

expect(screen.getByRole("button", { name: "docs" })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("button", { name: "empty" })).toBeDisabled();
expect(screen.getByRole("treeitem", { name: "docs" })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("treeitem", { name: "empty" })).not.toHaveAttribute("aria-expanded");
expect(screen.getByTestId("active-document-host")).toHaveTextContent("# Spec");
});

it("leaves focus on the revealed row after revealing from the File menu", async () => {
setDefaultSession({
folderContext: nestedFolderContext,
activeDocument: createSavedDocument({
path: SPEC_MARKDOWN_PATH,
content: "# Spec",
}),
});

const { user } = renderWithUser(<Shell />);

await user.click(screen.getByRole("menuitem", { name: "File" }));
await user.click(await screen.findByRole("menuitem", { name: /^Reveal in sidebar/u }));

expect(screen.getByRole("treeitem", { name: "spec.md" })).toHaveFocus();
});

it("shows the empty folder state while preserving empty directories", () => {
setDefaultSession({
folderContext: emptyFolderContext,
Expand All @@ -120,7 +143,7 @@ describe("Shell", () => {

expect(screen.getByText("No Markdown files found")).toBeInTheDocument();
expect(screen.getByText("No supported Markdown files found.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "nested" })).toBeDisabled();
expect(screen.getByRole("treeitem", { name: "nested" })).not.toHaveAttribute("aria-expanded");
});

it("hides the sidebar when the persisted sidebar setting is off", () => {
Expand All @@ -144,7 +167,7 @@ describe("Shell", () => {
);

const { user } = renderWithUser(<Shell />);
await user.click(screen.getByRole("button", { name: "readme.md" }));
await user.click(screen.getByRole("treeitem", { name: "readme.md" }));

await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Could not read Markdown file.", {
Expand All @@ -162,7 +185,7 @@ describe("Shell", () => {
mockTauriApiCommand("openMarkdownFile", () => Promise.reject(OVERSIZED_MARKDOWN_FILE_ERROR));

const { user } = renderWithUser(<Shell />);
await user.click(screen.getByRole("button", { name: "draft.markdown" }));
await user.click(screen.getByRole("treeitem", { name: "draft.markdown" }));

await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Markdown file is too large.", {
Expand Down
27 changes: 27 additions & 0 deletions src/components/ui/VirtualList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";

import { withPinnedIndexes } from "./VirtualList";

describe("withPinnedIndexes", () => {
it("leaves the rendered range alone when nothing is pinned", () => {
const indexes = [4, 5, 6];

expect(withPinnedIndexes(indexes, [])).toBe(indexes);
});

it("leaves the rendered range alone when every pinned row is already in it", () => {
const indexes = [4, 5, 6];

expect(withPinnedIndexes(indexes, [5, 6])).toBe(indexes);
});

it("adds pinned rows outside the range in index order", () => {
expect(withPinnedIndexes([4, 5, 6], [300])).toEqual([4, 5, 6, 300]);
expect(withPinnedIndexes([4, 5, 6], [0])).toEqual([0, 4, 5, 6]);
expect(withPinnedIndexes([9, 10, 11], [2, 40])).toEqual([2, 9, 10, 11, 40]);
});

it("adds a row pinned twice only once", () => {
expect(withPinnedIndexes([4, 5, 6], [40, 40])).toEqual([4, 5, 6, 40]);
});
});
35 changes: 33 additions & 2 deletions src/components/ui/VirtualList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { useVirtualizer, type ScrollToOptions, type VirtualItem } from "@tanstack/react-virtual";
import {
defaultRangeExtractor,
useVirtualizer,
type ScrollToOptions,
type VirtualItem,
} from "@tanstack/react-virtual";
import { Slot } from "radix-ui";
import {
createContext,
Expand Down Expand Up @@ -39,6 +44,7 @@ interface VirtualListProps<T> extends Omit<ComponentProps<typeof ScrollArea>, "v
getItemKey?: (item: T, index: number) => Key;
initialViewportHeight?: number;
overscan?: number;
pinnedIndexes?: number[];
virtualListRef?: Ref<VirtualListHandle>;
}

Expand All @@ -53,11 +59,14 @@ function VirtualList<T>({
getItemKey,
initialViewportHeight = estimateHeight * 16,
overscan = 8,
pinnedIndexes,
virtualListRef,
children,
...props
}: VirtualListProps<T>) {
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
const renderedPinnedIndexes =
pinnedIndexes?.filter((index) => index >= 0 && index < items.length) ?? [];

const virtualizer = useVirtualizer({
count: items.length,
Expand All @@ -69,6 +78,8 @@ function VirtualList<T>({
width: 0,
},
overscan,
rangeExtractor: (range) =>
withPinnedIndexes(defaultRangeExtractor(range), renderedPinnedIndexes),
});

useImperativeHandle(
Expand Down Expand Up @@ -113,6 +124,18 @@ function VirtualList<T>({
);
}

// Unmounting the row that holds focus drops focus to the document body, and takes
// the collection out of the tab sequence when that row is its only tab stop.
const withPinnedIndexes = (indexes: number[], pinnedIndexes: number[]) => {
const missingIndexes = Array.from(new Set(pinnedIndexes)).filter(
(pinnedIndex) => !indexes.includes(pinnedIndex),
);

return missingIndexes.length === 0
? indexes
: [...indexes, ...missingIndexes].sort((left, right) => left - right);
};

function VirtualListContent({
asChild = false,
className,
Expand Down Expand Up @@ -186,4 +209,12 @@ function VirtualListEmpty({ children }: { children: ReactNode }) {
return children;
}

export { VirtualList, VirtualListContent, VirtualListEmpty, VirtualListItem, VirtualListItems };
export {
VirtualList,
VirtualListContent,
VirtualListEmpty,
VirtualListItem,
VirtualListItems,
withPinnedIndexes,
type VirtualItem,
};
Loading