diff --git a/docs/frontend-ui-audit-2026-08-07/KeyboardOperability.md b/docs/frontend-ui-audit-2026-08-07/KeyboardOperability.md new file mode 100644 index 0000000000..9c6b96b961 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-07/KeyboardOperability.md @@ -0,0 +1,56 @@ +# Frontend UI Audit — Keyboard Operability + +**Files:** seven visible control sites listed below +**Date:** 2026-08-08 +**Auditor:** Codex follow-up on `junyu/fix-a11y-keyboard-controls` + +The D4 sweep found seven mouse-only controls. All reuse native button semantics or the existing `createKeyboardActivationHandler`; no new shared UI abstraction was needed. + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| -------------------------------- | ------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `DateRangeSelector/index.tsx:95` | native date trigger ` {isOpen && (
diff --git a/src/components/SearchInput/index.tsx b/src/components/SearchInput/index.tsx index 264298a421..10421db892 100644 --- a/src/components/SearchInput/index.tsx +++ b/src/components/SearchInput/index.tsx @@ -210,13 +210,20 @@ export const SearchInput: React.FC = memo(
{/* Expand/collapse chevron */} {onExpandToggle && !hideChevron && ( -
+
+ )} {/* Search input with inline options */} diff --git a/src/engines/ChatPanel/components/DebugJsonViewer/DebugJsonViewer.test.ts b/src/engines/ChatPanel/components/DebugJsonViewer/DebugJsonViewer.test.ts new file mode 100644 index 0000000000..d8c6719cd8 --- /dev/null +++ b/src/engines/ChatPanel/components/DebugJsonViewer/DebugJsonViewer.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { DebugJsonTreeBody } from "./index"; + +const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +describe("DebugJsonTreeBody keyboard semantics", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("makes expandable rows keyboard-operable and leaves primitive rows inert", () => { + act(() => + root.render(createElement(DebugJsonTreeBody, { data: { answer: 42 } })) + ); + + const rows = container.querySelectorAll(".json-node__row"); + expect(rows).toHaveLength(2); + expect(rows[0].getAttribute("role")).toBe("button"); + expect(rows[0].getAttribute("tabindex")).toBe("0"); + expect(rows[0].getAttribute("aria-expanded")).toBe("true"); + expect(rows[1].hasAttribute("role")).toBe(false); + expect(rows[1].hasAttribute("tabindex")).toBe(false); + expect(rows[1].onclick).toBeNull(); + + act(() => + rows[0].dispatchEvent( + new KeyboardEvent("keydown", { + key: " ", + bubbles: true, + cancelable: true, + }) + ) + ); + + expect(rows[0].getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelectorAll(".json-node__row")).toHaveLength(1); + }); +}); diff --git a/src/engines/ChatPanel/components/DebugJsonViewer/index.scss b/src/engines/ChatPanel/components/DebugJsonViewer/index.scss index 5a97551071..cb5e72cda3 100644 --- a/src/engines/ChatPanel/components/DebugJsonViewer/index.scss +++ b/src/engines/ChatPanel/components/DebugJsonViewer/index.scss @@ -35,6 +35,11 @@ &:hover { background: var(--color-fill-2); } + + &:focus-visible { + outline: 2px solid var(--color-primary-6); + outline-offset: -2px; + } } &__arrow { diff --git a/src/engines/ChatPanel/components/DebugJsonViewer/index.tsx b/src/engines/ChatPanel/components/DebugJsonViewer/index.tsx index 80231f12dd..e8254719d4 100644 --- a/src/engines/ChatPanel/components/DebugJsonViewer/index.tsx +++ b/src/engines/ChatPanel/components/DebugJsonViewer/index.tsx @@ -4,6 +4,8 @@ import { ChevronsDownUp, ChevronsUpDown } from "lucide-react"; import React, { memo, useCallback, useMemo, useState } from "react"; +import { createKeyboardActivationHandler } from "@src/util/dom/keyboardActivation"; + import "./index.scss"; // ============================================ @@ -35,6 +37,21 @@ const JsonNode: React.FC = memo( } }, [isExpandable]); + const handleToggleKeyDown = useMemo( + () => createKeyboardActivationHandler(toggleExpand), + [toggleExpand] + ); + + // Only expandable rows are interactive; primitive rows stay inert. + const interactiveRowProps = isExpandable + ? { + role: "button" as const, + tabIndex: 0, + "aria-expanded": isExpanded, + onKeyDown: handleToggleKeyDown, + } + : {}; + // Render primitive value const renderValue = () => { switch (valueType) { @@ -104,7 +121,11 @@ const JsonNode: React.FC = memo( return (
-
+
{/* Expand/Collapse Arrow */} {isExpandable && (isExpanded ? ( diff --git a/src/engines/GitWorkflow/GitHubDiff/DiffRow.tsx b/src/engines/GitWorkflow/GitHubDiff/DiffRow.tsx index f21a0a77ee..f229127b7e 100644 --- a/src/engines/GitWorkflow/GitHubDiff/DiffRow.tsx +++ b/src/engines/GitWorkflow/GitHubDiff/DiffRow.tsx @@ -8,6 +8,8 @@ import hljs from "highlight.js"; import { ChevronDown, ChevronRight, Minus, Plus } from "lucide-react"; import React, { useMemo } from "react"; +import { createKeyboardActivationHandler } from "@src/util/dom/keyboardActivation"; + import type { DiffRowProps, SplitDiffRowProps } from "./types"; // ============================================ @@ -233,7 +235,14 @@ interface CollapsedSectionProps { export const CollapsedSection: React.FC = React.memo( ({ lineCount, isExpanded, onToggle }) => { return ( -
+
{isExpanded ? : }
diff --git a/src/engines/GitWorkflow/GitHubDiff/index.scss b/src/engines/GitWorkflow/GitHubDiff/index.scss index 85833a54cd..95a43bfa59 100644 --- a/src/engines/GitWorkflow/GitHubDiff/index.scss +++ b/src/engines/GitWorkflow/GitHubDiff/index.scss @@ -341,6 +341,11 @@ background: var(--color-bg-4); color: var(--color-text-2); } + + &:focus-visible { + outline: 2px solid var(--color-primary-6); + outline-offset: -2px; + } } .diff-collapsed-icon { diff --git a/src/engines/Simulator/components/ListPanelSidebar/index.tsx b/src/engines/Simulator/components/ListPanelSidebar/index.tsx index 8e1df421b7..1fff112869 100644 --- a/src/engines/Simulator/components/ListPanelSidebar/index.tsx +++ b/src/engines/Simulator/components/ListPanelSidebar/index.tsx @@ -25,6 +25,7 @@ import { type GitFileStatus } from "@src/config/gitStatus"; import { SURFACE_TOKENS } from "@src/config/surfaceTokens"; import { AGENT_DOT_TOKENS } from "@src/engines/Simulator/config"; import { Placeholder } from "@src/modules/shared/layouts/blocks"; +import { createKeyboardActivationHandler } from "@src/util/dom/keyboardActivation"; import type { ListPanelContentProps, @@ -140,6 +141,11 @@ const DefaultListItem: React.FC = ({ [onCheckChange] ); + const handleKeyDown = useMemo( + () => createKeyboardActivationHandler(onClick), + [onClick] + ); + // Build full path for display const displayPath = item.secondaryText ? `${item.secondaryText}/${item.name}` @@ -147,12 +153,15 @@ const DefaultListItem: React.FC = ({ return (
{/* Checkbox (optional) */} {showCheckbox && ( diff --git a/src/features/CodeViewer/ModernSplitDiff.scss b/src/features/CodeViewer/ModernSplitDiff.scss index a3f3715ec5..4787609a23 100644 --- a/src/features/CodeViewer/ModernSplitDiff.scss +++ b/src/features/CodeViewer/ModernSplitDiff.scss @@ -551,6 +551,11 @@ $font-mono: background: var(--color-fill-2); margin: 0; + &:focus-visible { + outline: 2px solid var(--color-primary-6); + outline-offset: -2px; + } + .split-row-pane { padding: 0; background: var(--color-fill-2); diff --git a/src/features/CodeViewer/components/CollapseRow.tsx b/src/features/CodeViewer/components/CollapseRow.tsx index e6a35cbd3f..a96a7ba392 100644 --- a/src/features/CodeViewer/components/CollapseRow.tsx +++ b/src/features/CodeViewer/components/CollapseRow.tsx @@ -6,6 +6,8 @@ import { ArrowDownFromLine, ArrowUpFromLine, FoldVertical } from "lucide-react"; import React from "react"; +import { createKeyboardActivationHandler } from "@src/util/dom/keyboardActivation"; + import type { CollapsedSection } from "../types"; interface CollapseRowProps { @@ -28,7 +30,15 @@ export const CollapseRow: React.FC = ({ : FoldVertical; return ( -
+
{/* Left pane */}
diff --git a/src/modules/shared/layouts/blocks/BrowseCard.tsx b/src/modules/shared/layouts/blocks/BrowseCard.tsx index 2b29afbc71..4668af02e7 100644 --- a/src/modules/shared/layouts/blocks/BrowseCard.tsx +++ b/src/modules/shared/layouts/blocks/BrowseCard.tsx @@ -5,7 +5,9 @@ * and the Open VSX extension market. * * When `actionButton` is provided the chevron is replaced by the action area - * and the outer element becomes a `
` so the nested button is valid HTML. + * and the card's primary click target becomes a stretched sibling `
); } return ( - ); }; diff --git a/src/util/dom/__tests__/keyboardActivation.test.ts b/src/util/dom/__tests__/keyboardActivation.test.ts index 51e5224ead..87e2bb0785 100644 --- a/src/util/dom/__tests__/keyboardActivation.test.ts +++ b/src/util/dom/__tests__/keyboardActivation.test.ts @@ -32,16 +32,26 @@ describe("createKeyboardActivationHandler", () => { expect(action).toHaveBeenCalledTimes(1); }); + it("runs the action on Space and prevents default scrolling", () => { + const action = vi.fn(); + const preventDefault = vi.fn(); + const handler = createKeyboardActivationHandler(action); + + handler({ key: " ", preventDefault } as unknown as KeyboardEvent); + + expect(action).toHaveBeenCalledTimes(1); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); + it("does not run the action on unrelated keys", () => { const action = vi.fn(); + const preventDefault = vi.fn(); const handler = createKeyboardActivationHandler(action); - handler({ - key: "Escape", - preventDefault: vi.fn(), - } as unknown as KeyboardEvent); + handler({ key: "Escape", preventDefault } as unknown as KeyboardEvent); expect(action).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); }); });