diff --git a/apps/desktop/e2e/vc322-accessibility-smoke.mjs b/apps/desktop/e2e/vc322-accessibility-smoke.mjs new file mode 100644 index 000000000..cfeb1b9a0 --- /dev/null +++ b/apps/desktop/e2e/vc322-accessibility-smoke.mjs @@ -0,0 +1,399 @@ +/** Built-app dialog focus and title-ring regression probe. + * Run after pnpm build: node apps/desktop/e2e/vc322-accessibility-smoke.mjs + * Broader accessibility measurements are observational, not release sign-off. + * --observe disables the focus/title gate for exploratory measurements. + * No provider calls, credentials, live profile, clipboard, or VoiceOver automation. + */ +import { promises as fs } from "node:fs"; +import { resolve, join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { + launch, + assertBuiltRendererLoaded, + assertProfileIsolated, + seedProjects, + makeGitRepo, + startTerminalSession, + closeAppBounded, +} from "./lib/smoke-kit.mjs"; +const output = resolve( + process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? "evidence/vc322", +); +const gateFixes = !process.argv.includes("--observe"); +await fs.mkdir(output, { recursive: true }); +const scratch = await fs.mkdtemp(join(output, "run-")); +const userDataDir = join(scratch, "user-data"); +await fs.mkdir(userDataDir); +const project = await makeGitRepo(scratch, "project-"); +const app = await launch({ + dbPath: join(scratch, "volli.db"), + userDataDir, + extraEnv: { HOME: join(scratch, "home"), VOLLI_AGENT_HOME: join(scratch, "home") }, +}); +const page = await app.firstWindow(); +page.setDefaultTimeout(8000); +const report = { + sha: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(), + dirty: execFileSync("git", ["status", "--short"], { encoding: "utf8" }), + checks: [], + observations: [], +}; +const active = () => + page.evaluate(() => { + const e = document.activeElement, + s = getComputedStyle(e); + return { + tag: e.tagName, + role: e.getAttribute("role"), + label: e.getAttribute("aria-label"), + text: e.textContent?.slice(0, 100), + placeholder: e.getAttribute("placeholder"), + focusVisible: e.matches(":focus-visible"), + outline: s.outline, + boxShadow: s.boxShadow, + border: s.border, + background: s.backgroundColor, + dialog: !!e.closest("[role=dialog]"), + }; + }); +async function check(name, fn) { + try { + const detail = await fn(); + report.checks.push({ name, ...detail }); + console.log(JSON.stringify(report.checks.at(-1))); + } catch (e) { + report.checks.push({ name, pass: false, error: e.message }); + console.log(name, e.message); + await page.keyboard.press("Escape"); + } +} +async function tabTour(name, count = 45) { + const steps = []; + for (let i = 0; i < count; i++) { + await page.keyboard.press("Tab"); + await page.waitForTimeout(200); + steps.push(await active()); + } + report.observations.push({ name, steps }); +} +try { + await page.waitForLoadState("domcontentloaded"); + assertBuiltRendererLoaded(page); + await assertProfileIsolated(app, userDataDir); + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].setContentSize(1440, 900), + ); + await seedProjects(page, [ + { id: "a11y-project", name: "Accessibility release", path: project, prefix: "AX" }, + ]); + await page.evaluate(async () => { + const b = await window.api.data.bootstrap(); + const p = b.data.projects[0]; + for (const [title, status] of [ + ["Keyboard ticket", "todo"], + ["Second ticket", "doing"], + ]) + await window.api.tickets.create({ + projectId: p.id, + title, + status, + priority: "medium", + description: "# Release body\n\nKeyboard and contrast fixture.", + }); + }); + await page.reload(); + await page.getByRole("button", { name: "New ticket", exact: true }).waitFor(); + await page.screenshot({ path: join(output, "board.png") }); + await fs.writeFile(join(output, "board-ax.txt"), await page.locator("body").ariaSnapshot()); + await tabTour("board keyboard focus"); + await check("New ticket keyboard entry, trapped focus, Escape and focus return", async () => { + const trigger = page.getByRole("button", { name: "New ticket", exact: true }); + await trigger.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("dialog").waitFor(); + const entered = await active(); + const inside = []; + for (let i = 0; i < 18; i++) { + await page.keyboard.press("Tab"); + await page.waitForTimeout(200); + inside.push(await active()); + } + report.observations.push({ name: "dialog tab path before Escape", inside }); + await page.getByPlaceholder("Ticket title").focus(); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + await page.waitForTimeout(250); + const returned = await trigger.evaluate((e) => e === document.activeElement); + return { + pass: entered.dialog && inside.every((e) => e.dialog) && returned, + entered, + returned, + after: await active(), + inside, + }; + }); + await check("Command palette Escape returns keyboard focus", async () => { + const trigger = page.getByRole("button", { name: "New ticket", exact: true }); + await trigger.focus(); + await page.keyboard.press("Meta+K"); + await page.getByRole("dialog").waitFor(); + const entered = await active(); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + await page.waitForTimeout(250); + const returned = await trigger.evaluate((e) => e === document.activeElement); + return { pass: entered.dialog && returned, entered, returned, after: await active() }; + }); + await check("Board card Enter opens ticket; Escape returns to card", async () => { + const card = page + .locator('[data-board-ticket-slot] > [role="button"]') + .filter({ hasText: "AX-1" }) + .first(); + await card.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("tablist", { name: "Ticket tabs" }).waitFor(); + const opened = await active(); + await page.keyboard.press("Escape"); + await page.getByRole("tab", { name: "Board", exact: true }).waitFor(); + await page.waitForTimeout(250); + return { + pass: await card.evaluate((e) => e === document.activeElement), + opened, + after: await active(), + }; + }); + await check("List row keyboard open and return", async () => { + await page.getByRole("button", { name: "List view", exact: true }).click(); + await fs.writeFile(join(output, "list-ax.txt"), await page.locator("body").ariaSnapshot()); + const row = page.locator('[data-ticket-id="AX-1"]').first(); + await row.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("tablist", { name: "Ticket tabs" }).waitFor(); + await page.screenshot({ path: join(output, "ticket.png") }); + await fs.writeFile(join(output, "ticket-ax.txt"), await page.locator("body").ariaSnapshot()); + await page.locator('h1[role="button"]').focus(); + await page.waitForTimeout(200); + report.observations.push({ name: "ticket title focus", state: await active() }); + await tabTour("ticket body comments and rail", 12); + await page.keyboard.press("Control+Shift+M"); + await page.keyboard.press("Tab"); + report.observations.push({ + name: "Monaco Tab escape after Control+Shift+M", + after: await active(), + }); + await page.getByRole("tablist", { name: "Ticket tabs" }).getByRole("tab").first().focus(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "List view", exact: true }).waitFor(); + await page.waitForTimeout(250); + return { pass: await row.evaluate((e) => e === document.activeElement), after: await active() }; + }); + // Native theme is the app's source of truth in auto mode. No custom canvases. + for (const mode of ["dark", "light"]) { + await app.evaluate(({ nativeTheme }, m) => { + nativeTheme.themeSource = m; + }, mode); + await page.waitForFunction((m) => document.documentElement.classList.contains(m), mode); + await page.getByRole("button", { name: "New ticket", exact: true }).focus(); + await page.keyboard.press("Enter"); + await page.getByRole("dialog").waitFor(); + const tokens = await page.evaluate(() => { + const root = getComputedStyle(document.documentElement), + names = [ + "background", + "card", + "popover", + "foreground", + "muted-foreground", + "primary", + "primary-foreground", + "primary-text", + "ring", + "border", + "destructive", + "positive", + "attention", + "info", + ]; + // Runs in the renderer, not the Node process that owns this probe. + // eslint-disable-next-line unicorn/consistent-function-scoping + const rgb = (value) => { + const e = document.createElement("span"); + e.style.color = value; + document.body.append(e); + const c = getComputedStyle(e).color; + e.remove(); + return c; + }; + return Object.fromEntries(names.map((n) => [n, rgb(root.getPropertyValue("--" + n))])); + }); + report.observations.push({ name: `${mode} default canvas tokens`, tokens }); + report.observations.push({ + name: `${mode} dialog controls`, + controls: await page + .getByRole("dialog") + .locator("button,input,textarea") + .evaluateAll((es) => + es.map((e) => { + const s = getComputedStyle(e); + return { + text: e.textContent, + label: e.getAttribute("aria-label"), + color: s.color, + background: s.backgroundColor, + fontSize: s.fontSize, + disabled: e.disabled, + }; + }), + ), + }); + await page.screenshot({ path: join(output, `${mode}-dialog.png`) }); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + } + for (const reducedMotion of ["no-preference", "reduce"]) { + await page.emulateMedia({ reducedMotion }); + await page.getByRole("button", { name: "New ticket", exact: true }).focus(); + await page.keyboard.press("Enter"); + await page.getByRole("dialog").waitFor(); + report.observations.push({ + name: `motion ${reducedMotion}`, + matches: await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches), + animations: await page.evaluate(() => + document.getAnimations().map((a) => ({ + target: a.effect?.target?.outerHTML.slice(0, 250), + timing: a.effect?.getTiming(), + keyframes: a.effect?.getKeyframes(), + state: a.playState, + })), + ), + styles: await page.getByRole("dialog").evaluate((e) => { + const s = getComputedStyle(e); + return { animation: s.animation, transition: s.transition }; + }), + }); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + } + for (const zoom of [1.25, 1.5, 2]) { + await app.evaluate( + ({ BrowserWindow }, z) => BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(z), + zoom, + ); + await page.getByRole("button", { name: "New ticket", exact: true }).focus(); + await page.keyboard.press("Enter"); + await page.getByRole("dialog").waitFor(); + const bounds = await page.getByRole("dialog").evaluate((e) => { + const r = e.getBoundingClientRect(); + return { + x: r.x, + y: r.y, + width: r.width, + height: r.height, + viewport: [innerWidth, innerHeight], + scrollWidth: e.scrollWidth, + clientWidth: e.clientWidth, + }; + }); + await page.screenshot({ path: join(output, `zoom-${zoom}.png`) }); + report.checks.push({ + name: `BrowserWindow zoom ${zoom} dialog fits`, + pass: + bounds.x >= 0 && + bounds.y >= 0 && + bounds.x + bounds.width <= bounds.viewport[0] + 1 && + bounds.y + bounds.height <= bounds.viewport[1] + 1, + bounds, + }); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + } + + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(1), + ); + for (let step = 0; step < 5; step++) { + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].webContents.send("volli:ui-zoom-command", "in"), + ); + await page.waitForTimeout(250); + const scale = await page.evaluate( + async () => + JSON.parse((await window.api.data.bootstrap()).data.appState["volli:ui"]).state.uiScale, + ); + if (step >= 1) { + await page.screenshot({ path: join(output, `app-zoom-step-${step}.png`) }); + report.observations.push({ name: "native app zoom command", step, scale }); + } + } + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].webContents.send("volli:ui-zoom-command", "reset"), + ); + await check("Terminal keyboard boundary", async () => { + await startTerminalSession(page); + const input = page.locator(".xterm-helper-textarea").last(); + await input.waitFor({ state: "attached" }); + await input.focus(); + await page.keyboard.type("printf 'VC322-TERMINAL-OUTPUT\\n'"); + await page.keyboard.press("Enter"); + await page.waitForTimeout(800); + const steps = []; + for (const key of ["Tab", "Shift+Tab", "Escape", "Meta+Alt+Enter", "Meta+Alt+Enter"]) { + await page.keyboard.press(key); + await page.waitForTimeout(200); + steps.push({ key, active: await active() }); + } + // VC-344 deliberately preserves PTY Tab bytes by default. Test the + // advertised focus-navigation mode, rather than calling raw Tab a trap. + await input.focus(); + await page.keyboard.press("Control+Shift+M"); + const navigation = []; + for (const key of ["Tab", "Shift+Tab"]) { + await input.focus(); + await page.keyboard.press(key); + await page.waitForTimeout(200); + navigation.push({ + key, + outsideTerminal: await page.evaluate( + () => !document.activeElement?.closest("[data-terminal-renderer]"), + ), + active: await active(), + }); + } + const semantics = await page + .locator("[data-terminal-renderer]") + .last() + .evaluate((e) => ({ + role: e.getAttribute("role"), + label: e.getAttribute("aria-label"), + tree: e.outerHTML.slice(0, 1200), + screenReaderLayer: !!e.querySelector(".xterm-accessibility"), + })); + await fs.writeFile(join(output, "terminal-ax.txt"), await page.locator("body").ariaSnapshot()); + return { + pass: + navigation.every((step) => step.outsideTerminal) && + semantics.role === "region" && + Boolean(semantics.label) && + semantics.screenReaderLayer, + steps, + navigation, + semantics, + }; + }); +} finally { + await fs.writeFile(join(output, "report.json"), JSON.stringify(report, null, 2)); + await closeAppBounded(app); +} +// The broader accessibility measurements remain observational. The CI runner +// invokes probes without flags, so focus/title regressions must fail by default. +if (gateFixes) { + const dialogs = report.checks.slice(0, 2); + const title = report.observations.find((item) => item.name === "ticket title focus")?.state; + if ( + dialogs.length !== 2 || + dialogs.some((item) => !item.pass) || + !title?.focusVisible || + !title.boxShadow.includes("2px") + ) + process.exitCode = 1; +} diff --git a/apps/desktop/e2e/vc322-browser-boundary.mjs b/apps/desktop/e2e/vc322-browser-boundary.mjs new file mode 100644 index 000000000..1327dd8af --- /dev/null +++ b/apps/desktop/e2e/vc322-browser-boundary.mjs @@ -0,0 +1,92 @@ +/** Built-app VC-301 keyboard-boundary observation. Local fixture only. */ +import { promises as fs } from "node:fs"; +import http from "node:http"; +import { resolve, join } from "node:path"; +import { + launch, + seedProjects, + makeGitRepo, + assertBuiltRendererLoaded, + assertProfileIsolated, + closeAppBounded, +} from "./lib/smoke-kit.mjs"; +const nativeWindow = process.argv.includes("--native-window"); +const out = resolve( + process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? "evidence/vc322/browser-boundary", +); +await fs.mkdir(out, { recursive: true }); +const scratch = await fs.mkdtemp(join(out, "run-")); +const userDataDir = join(scratch, "user-data"); +await fs.mkdir(userDataDir); +const server = http.createServer((_req, res) => + res.end( + `Keyboard fixture + + `, + ), +); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +const url = `http://127.0.0.1:${server.address().port}/`; +const app = await launch({ + dbPath: join(scratch, "volli.db"), + userDataDir, + extraEnv: { + HOME: join(scratch, "home"), + VOLLI_QUIET_WINDOWS: nativeWindow ? "0" : "1", + }, +}); +const results = []; +try { + const page = await app.firstWindow(); + await page.waitForLoadState("domcontentloaded"); + assertBuiltRendererLoaded(page); + await assertProfileIsolated(app, userDataDir); + const path = await makeGitRepo(scratch, "project-"); + await seedProjects(page, [{ id: "boundary", name: "Boundary", prefix: "KB", path }]); + await page.getByRole("button", { name: "Other things to open", exact: true }).click(); + await page.getByRole("menuitem", { name: "Browser", exact: true }).click(); + const address = page.getByRole("textbox", { name: "Address", exact: true }); + await address.fill(url); + await address.press("Enter"); + await page.getByRole("tab", { name: "Keyboard fixture", exact: true }).waitFor(); + for (const key of [ + { keyCode: "k", modifiers: ["meta"] }, + { keyCode: "Escape", modifiers: [] }, + { keyCode: "Tab", modifiers: ["shift"] }, + ]) { + await app.evaluate( + ({ webContents, BrowserWindow }, input) => { + if (input.nativeWindow) BrowserWindow.getAllWindows()[0].focus(); + const wc = webContents.getAllWebContents().find((w) => w.getURL() === input.url); + wc.focus(); + wc.sendInputEvent({ type: "keyDown", ...input.key }); + wc.sendInputEvent({ type: "keyUp", ...input.key }); + }, + { url, key, nativeWindow }, + ); + await page.waitForTimeout(300); + results.push({ + key, + nativeWindow, + // The fixture is authored above: prove delivery, not just a missing modal. + delivery: await app.evaluate(async ({ webContents }, fixtureUrl) => { + const wc = webContents.getAllWebContents().find((w) => w.getURL() === fixtureUrl); + return wc.executeJavaScript( + "({keys:window.fixtureKeys,active:document.activeElement.tagName})", + ); + }, url), + dialogs: await page.getByRole("dialog").count(), + focus: await app.evaluate(({ webContents }) => { + const w = webContents.getFocusedWebContents(); + return w ? { url: w.getURL(), type: w.getType() } : null; + }), + }); + } + await fs.writeFile(join(out, "report.json"), JSON.stringify(results, null, 2)); + console.log(JSON.stringify(results, null, 2)); +} finally { + await closeAppBounded(app); + await new Promise((r) => server.close(r)); +} diff --git a/apps/desktop/src/renderer/src/components/board/new-ticket/new-ticket-dialog.tsx b/apps/desktop/src/renderer/src/components/board/new-ticket/new-ticket-dialog.tsx index 17aeb4a39..eebf3ff4a 100644 --- a/apps/desktop/src/renderer/src/components/board/new-ticket/new-ticket-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/board/new-ticket/new-ticket-dialog.tsx @@ -4,6 +4,7 @@ import { PROMPT_SURFACE } from "@renderer/components/chat/composer-chrome"; import { ComposerForm } from "@renderer/components/board/new-ticket/composer-form"; import { Dialog, DialogContent, DialogTitle } from "@renderer/components/ui/dialog"; import { useSelectedProject } from "@renderer/hooks/use-selected-project"; +import { useDialogFocusReturn } from "@renderer/hooks/use-dialog-focus-return"; import { cn } from "@renderer/lib/utils"; import { useUiStore } from "@renderer/stores/ui"; @@ -27,6 +28,7 @@ export function NewTicketDialog() { const project = useSelectedProject(); const newTicketOpen = useUiStore((state) => state.newTicketOpen); const open = newTicketOpen && project !== null; + const { restoreFocus } = useDialogFocusReturn(open); const [expanded, setExpanded] = React.useState(false); return ( @@ -41,6 +43,7 @@ export function NewTicketDialog() { > + + + + + Search tickets, sessions, and commands + + + {children} + + + + + ); +} + /** No tickets/sessions to show while closed — keeps the derivation below free. */ const EMPTY_COMMAND_PALETTE_ITEMS: CommandPaletteItems = { tickets: [], sessions: [] }; @@ -186,6 +225,8 @@ function ShowAllRow({ sectionId, total, noun, onExpand }: ShowAllRowProps) { * the rows `@` itself surfaces — narrows the palette to one section. */ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { + const { restoreFocus, skipFocusReturn } = useDialogFocusReturn(open); + const projects = useProjectsStore((state) => state.projects); const selectedProjectId = useProjectsStore((state) => state.selectedProjectId); const ticketsByProject = useBoardStore((state) => state.ticketsByProject); @@ -424,9 +465,12 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { }, [open, projectIds]); const finishNavigation = React.useCallback(() => { + // Navigation intentionally hands focus to the destination rather than the + // palette's invoker. Ordinary dismissal restores after the focus scope closes. + skipFocusReturn(); useUiStore.getState().setSettingsOpen(false); onOpenChange(false); - }, [onOpenChange]); + }, [onOpenChange, skipFocusReturn]); // The Go to section, mounted in one of two places: first when the query // names one of its commands, last otherwise (see `navigationLeads` for why @@ -483,20 +527,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { const navigationFirst = navigationLeads(navigationSlice, query); return ( - + {/* 36px, matching the footer strip: the header was 48 — a full rung taller than any control this app draws — so the field read as a hero banner rather than as the search box the rows answer to. `text-ui` is the @@ -847,6 +878,6 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { ↑↓ navigate ↵ open - + ); } diff --git a/apps/desktop/src/renderer/src/components/ticket/ticket-title.test.tsx b/apps/desktop/src/renderer/src/components/ticket/ticket-title.test.tsx new file mode 100644 index 000000000..8090588eb --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ticket/ticket-title.test.tsx @@ -0,0 +1,41 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; +import type { Ticket } from "@volli/shared"; + +import { TicketTitle } from "./ticket-title"; + +vi.mock("@renderer/stores/board", () => ({ + useBoardStore: () => vi.fn(), +})); + +const ticket: Ticket = { + id: "t1", + projectId: "p1", + ticketNumber: 1, + title: "Keyboard focus", + body: "", + status: "todo", + priority: "medium", + labels: [], + usesWorktree: false, + preferredHarnessId: "claude-code", + order: 0, + worktreePath: null, + branch: null, + baseBranch: null, + prUrl: null, + createdAt: 1, + updatedAt: 1, +}; + +describe("TicketTitle", () => { + it("keeps a visible semantic-token focus ring on the keyboard trigger", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('role="button"'); + expect(html).toContain("focus-visible:ring-2"); + expect(html).toContain("focus-visible:ring-ring"); + expect(html).not.toContain("focus-visible:ring-ring/"); + expect(html).toContain("focus-visible:outline-none"); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx b/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx index 03c56dbb4..860371a44 100644 --- a/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx +++ b/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx @@ -63,7 +63,7 @@ export function TicketTitle({ ticket }: { ticket: Ticket }) { setEditing(true); } }} - className="cursor-text text-title font-semibold text-foreground outline-none" + className="cursor-text text-title font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" > {ticket.title} diff --git a/apps/desktop/src/renderer/src/components/ui/dialog-focus.test.tsx b/apps/desktop/src/renderer/src/components/ui/dialog-focus.test.tsx new file mode 100644 index 000000000..9e80d8fe8 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/dialog-focus.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +import * as React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { useDialogFocusReturn } from "@renderer/hooks/use-dialog-focus-return"; +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "./dialog"; + +let root: Root; +let host: HTMLDivElement; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); +}); + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} +function Fixture({ + trigger = false, + override = false, + skip = false, +}: { + trigger?: boolean; + override?: boolean; + skip?: boolean; +}) { + const [open, setOpen] = React.useState(false); + const { restoreFocus, skipFocusReturn } = useDialogFocusReturn(open); + return ( + <> + + + {trigger ? ( + Open + ) : ( + + )} + { + event.preventDefault(); + document.getElementById("destination")?.focus(); + restoreFocus(event); + } + : restoreFocus + } + > + Fixture + + + + + + ); +} +for (const trigger of [false, true]) + it(`restores keyboard invoker after dismissal (Radix trigger ${trigger})`, async () => { + await act(async () => root.render()); + const invoker = document.getElementById("invoker")!; + await act(async () => { + invoker.focus(); + invoker.click(); + }); + await settle(); + expect(document.activeElement?.closest('[role="dialog"]')).not.toBeNull(); + await act(async () => document.getElementById("dismiss")!.click()); + await settle(); + expect(document.activeElement).toBe(invoker); + }); +it("leaves default close focus alone when no HTML invoker exists", async () => { + const close = new Event("closeAutoFocus", { cancelable: true }); + function NoInvoker() { + const { restoreFocus } = useDialogFocusReturn(true); + React.useLayoutEffect(() => restoreFocus(close), [restoreFocus]); + return null; + } + const active = vi.spyOn(document, "activeElement", "get").mockReturnValue(null); + try { + await act(async () => root.render()); + expect(close.defaultPrevented).toBe(false); + } finally { + active.mockRestore(); + } +}); +it("honours a caller's explicit focus handoff", async () => { + await act(async () => root.render()); + await act(async () => { + document.getElementById("invoker")!.focus(); + document.getElementById("invoker")!.click(); + }); + await settle(); + await act(async () => document.getElementById("dismiss")!.click()); + await settle(); + expect(document.activeElement).toBe(document.getElementById("destination")); +}); +it("does not pull focus back after a navigation selection", async () => { + await act(async () => root.render()); + const invoker = document.getElementById("invoker")!; + await act(async () => { + invoker.focus(); + invoker.click(); + }); + await settle(); + const focus = vi.spyOn(invoker, "focus"); + await act(async () => document.getElementById("dismiss")!.click()); + await settle(); + expect(focus).not.toHaveBeenCalled(); +}); +it("ignores an invoker removed while the dialog is open", async () => { + await act(async () => root.render()); + const invoker = document.getElementById("invoker")!; + await act(async () => { + invoker.focus(); + invoker.click(); + }); + await settle(); + invoker.remove(); + const focus = vi.spyOn(invoker, "focus"); + await act(async () => document.getElementById("dismiss")!.click()); + await settle(); + expect(focus).not.toHaveBeenCalled(); + // Restore React's host node before teardown. + host.append(invoker); +}); diff --git a/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.node.test.tsx b/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.node.test.tsx new file mode 100644 index 000000000..886b2c8fe --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.node.test.tsx @@ -0,0 +1,22 @@ +// @vitest-environment node +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { useDialogFocusReturn } from "./use-dialog-focus-return"; + +function Fixture({ open }: { open: boolean }) { + useDialogFocusReturn(open); + return {open ? "open" : "closed"}; +} + +describe("dialog focus return without a DOM", () => { + for (const open of [false, true]) { + it(`renders with open=${open} without reading browser globals`, () => { + expect(typeof document).toBe("undefined"); + expect(typeof HTMLElement).toBe("undefined"); + expect(renderToStaticMarkup()).toBe( + `${open ? "open" : "closed"}`, + ); + }); + } +}); diff --git a/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.ts b/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.ts new file mode 100644 index 000000000..f4bfdddb9 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.ts @@ -0,0 +1,34 @@ +import * as React from "react"; + +/** A controlled dialog has no Radix Trigger to restore. Sample its invoker + * during the opening render: an autoFocus child has already taken focus by + * onOpenAutoFocus. Restore only after Radix releases the closing focus scope. + */ +export function useDialogFocusReturn(open: boolean) { + const candidate = React.useMemo(() => { + // The chrome is also rendered by Node-only static-markup tests. A closed + // palette needs no invoker, and server rendering cannot supply one. + if (!open || typeof document === "undefined") return null; + const active = document.activeElement; + return active instanceof HTMLElement ? active : null; + }, [open]); + const invoker = React.useRef(null); + const skipped = React.useRef(false); + React.useLayoutEffect(() => { + if (open) { + invoker.current = candidate; + skipped.current = false; + } + }, [open, candidate]); + const skipFocusReturn = React.useCallback(() => { + skipped.current = true; + }, []); + const restoreFocus = React.useCallback((event: Event) => { + const target = invoker.current; + invoker.current = null; + if (event.defaultPrevented || skipped.current || !target?.isConnected) return; + event.preventDefault(); + target.focus({ preventScroll: true }); + }, []); + return { restoreFocus, skipFocusReturn }; +}