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 @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Stepping through comments with `n`/`p` is now re-announced to screen readers even when the move lands on the same position — e.g. wrapping around on a page with a single open comment, where the position text is identical each press. Previously the polite live region's text was unchanged, so NVDA/JAWS/VoiceOver stayed silent despite the move.
- A reply draft typed into one comment thread no longer appears in every other thread. Drafts are now scoped to the thread they were written in: switching threads (with `n`/`p` or the prev/next buttons) shows each thread's own draft, an untouched thread stays empty, and returning to a thread restores the draft you left there. Drafts clear on submit and when you navigate to another page.
- Comments from the previous page no longer briefly reappear after navigating to a page that shows none (e.g. Home): a now-superseded comment fetch that resolves after you've navigated away is dropped instead of repopulating the just-cleared list. Resolving or reopening a comment while a navigation is in flight likewise no longer writes the change into the next page's comment view.
- Accessibility: the active navigation link and the active "On this page" outline entry now expose `aria-current`, so screen readers announce which page and heading you're on (previously conveyed by color alone); copying a comment's share link is announced via a polite live region rather than only swapping the button icon; and the desktop navigation landmark now carries an accessible name ("Documentation"), distinct from the breadcrumb, table-of-contents, and mobile-navigation landmarks.

## [0.1.26] - 2026-06-21

Expand Down
18 changes: 18 additions & 0 deletions packages/viewer/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,22 @@ test.describe("Navigation", () => {
await expect(aside.getByRole("link", { name: "Plugin Development" })).toBeVisible();
await expect(aside.getByRole("link", { name: "Advanced Topics" })).toBeVisible();
});

test("active nav link exposes aria-current=page", async ({ page }) => {
await page.goto("/getting-started/installation");

const aside = page.getByRole("complementary", { name: "Sidebar" });
const active = aside.getByRole("link", { name: "Installation" });
await expect(active).toHaveAttribute("aria-current", "page");

// A non-active leaf sibling under the same section must not carry it,
// confirming aria-current marks only the current page, not the whole branch.
const inactive = aside.getByRole("link", { name: "Configuration" });
await expect(inactive).not.toHaveAttribute("aria-current", "page");
});

test("desktop nav landmark has an accessible name", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("navigation", { name: "Documentation" })).toBeVisible();
});
});
1 change: 1 addition & 0 deletions packages/viewer/src/components/NavItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@

<a
href={item.href ?? router.prefixPath(item.path)}
aria-current={isActive ? "page" : undefined}
class="
flex-1 rounded-sm p-1.5 text-sm transition-colors
{isActive
Expand Down
2 changes: 1 addition & 1 deletion packages/viewer/src/components/NavigationSidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
{:else if navigation.error}
<Alert intent="danger">{navigation.error}</Alert>
{:else if navigation.tree}
<nav>
<nav aria-label="Documentation">
{#if navigation.tree.scope && backLink}
<div class="mb-5">
<a
Expand Down
1 change: 1 addition & 0 deletions packages/viewer/src/components/TocSidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<li class={{ "ml-3": entry.level === 3 }}>
<a
href="#{entry.id}"
aria-current={activeId === entry.id ? "true" : undefined}
onclick={(e) => {
e.preventDefault();
onNavigate(entry.id);
Expand Down
29 changes: 29 additions & 0 deletions packages/viewer/src/components/TocSidebar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/svelte";
import TocSidebar from "./TocSidebar.svelte";
import type { TocEntry } from "../types";

const noop = () => {};

const toc: TocEntry[] = [
{ id: "features", title: "Features", level: 2 },
{ id: "quick-start", title: "Quick Start", level: 2 },
];

describe("TocSidebar aria-current", () => {
it('marks the active entry with aria-current="true"', () => {
const { getByRole } = render(TocSidebar, { toc, activeId: "quick-start", onNavigate: noop });
expect(getByRole("link", { name: "Quick Start" }).getAttribute("aria-current")).toBe("true");
});

it("omits aria-current on inactive entries", () => {
const { getByRole } = render(TocSidebar, { toc, activeId: "quick-start", onNavigate: noop });
expect(getByRole("link", { name: "Features" }).getAttribute("aria-current")).toBeNull();
});

it("omits aria-current on every entry when nothing is active", () => {
const { getByRole } = render(TocSidebar, { toc, activeId: null, onNavigate: noop });
expect(getByRole("link", { name: "Features" }).getAttribute("aria-current")).toBeNull();
expect(getByRole("link", { name: "Quick Start" }).getAttribute("aria-current")).toBeNull();
});
});
7 changes: 7 additions & 0 deletions packages/viewer/src/components/comments/CommentThread.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,13 @@
</svg>
{/if}
</button>
<!-- A polite atomic live region (equivalent to role="status", but
without the role) announces the copy. Using the bare live region
avoids adding a second status landmark to the page alongside the
page-level comment-navigation status region. -->
<span class="sr-only" aria-live="polite" aria-atomic="true">
{copied ? "Link copied" : ""}
</span>
<span class="text-xs text-gray-400 dark:text-neutral-500">
{formatRelativeTime(new Date(comment.createdAt))}
</span>
Expand Down
48 changes: 46 additions & 2 deletions packages/viewer/src/components/comments/CommentThread.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { describe, it, expect, vi, beforeAll } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent } from "@testing-library/svelte";
import { tick } from "svelte";
import { MockResizeObserver } from "$lib/ui/hooks/__fixtures__/resize-observer-mock";

import CommentThread from "./CommentThread.svelte";
import type { Comment } from "../../types/comments";

beforeAll(() => {
// Re-stub ResizeObserver per test so the afterEach teardown (which restores any
// globals a test stubbed, e.g. navigator in the copy-link test) cannot leave a
// later test without it.
beforeEach(() => {
vi.stubGlobal("ResizeObserver", MockResizeObserver);
});

afterEach(() => {
vi.unstubAllGlobals();
});

function mkComment(over: Partial<Comment> & { id: string }): Comment {
return {
documentId: "doc",
Expand Down Expand Up @@ -128,6 +136,42 @@ describe("CommentThread copy-link button", () => {
});
});

describe("CommentThread copy-link announcement", () => {
it("announces the copy in a polite live region, then clears it after the reset", async () => {
// Fake timers make the 1500ms auto-reset deterministic and let us assert the
// region returns to empty (so it won't keep announcing on the next focus).
vi.useFakeTimers();
try {
// copyLink() only touches navigator.clipboard.writeText, so stub just that
// surface. (A {...navigator} spread would drop navigator's prototype getters
// and yield a degraded object.) The afterEach restores it.
vi.stubGlobal("navigator", {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
const { getByRole, container } = renderThread({ id: "1" });

const status = container.querySelector<HTMLElement>('[aria-live="polite"]')!;
expect(status).not.toBeNull();
expect(status.getAttribute("aria-atomic")).toBe("true");
expect(status.textContent?.trim()).toBe("");

await fireEvent.click(getByRole("button", { name: "Copy link" }));
// copyLink awaits the (immediately-resolved) clipboard write before setting
// copied=true; advancing fake timers flushes that microtask, then tick()
// flushes Svelte's DOM update.
await vi.advanceTimersByTimeAsync(0);
await tick();
expect(status.textContent?.trim()).toBe("Link copied");

await vi.advanceTimersByTimeAsync(1500);
await tick();
expect(status.textContent?.trim()).toBe("");
} finally {
vi.useRealTimers();
}
});
});

describe("CommentThread canResolve gating", () => {
it("shows Resolve when canResolve is true", () => {
const { getByRole } = renderThread({ id: "1", canResolve: true, status: "open" });
Expand Down
Loading