From fa37a7053d182df22dcf25c4713ba9b3a6eb7d11 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Fri, 7 Aug 2026 22:35:25 +0800 Subject: [PATCH] fix(a11y): make click-only controls keyboard operable --- .../KeyboardOperability.md | 40 ++++++++++++++++++ src/components/DateRangeSelector/index.tsx | 11 +++-- src/components/SearchInput/index.tsx | 11 ++++- .../components/DebugJsonViewer/index.scss | 5 +++ .../components/DebugJsonViewer/index.tsx | 23 +++++++++- .../GitWorkflow/GitHubDiff/DiffRow.tsx | 11 ++++- src/engines/GitWorkflow/GitHubDiff/index.scss | 5 +++ .../components/ListPanelSidebar/index.tsx | 11 ++++- src/features/CodeViewer/ModernSplitDiff.scss | 5 +++ .../CodeViewer/components/CollapseRow.tsx | 12 +++++- .../shared/layouts/blocks/BrowseCard.tsx | 42 ++++++++++++------- .../dom/__tests__/keyboardActivation.test.ts | 18 ++++++-- 12 files changed, 167 insertions(+), 27 deletions(-) create mode 100644 docs/frontend-ui-audit-2026-08-07/KeyboardOperability.md 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 000000000..ea4e1480c --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-07/KeyboardOperability.md @@ -0,0 +1,40 @@ +# Frontend UI Audit — Keyboard Operability (repeated click-only controls) + +**Scope:** 7 click-only interactive sites flagged by the D4 (accessibility) sweep — non-semantic elements (`div`/`span` + `onClick`) with no keyboard path. +**Date:** 2026-08-07 +**Auditor:** fix(a11y) keyboard-operability session (branch `junyu/fix-a11y-keyboard-controls`) +**Baseline:** line numbers refer to `origin/develop` (b83a28ad6) _before_ the fix landed. + +All 7 sites reuse the existing `createKeyboardActivationHandler` / native ` {isOpen && (
diff --git a/src/components/SearchInput/index.tsx b/src/components/SearchInput/index.tsx index 264298a42..10421db89 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/index.scss b/src/engines/ChatPanel/components/DebugJsonViewer/index.scss index 5a9755107..cb5e72cda 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 80231f12d..e9a6c03a7 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 f21a0a77e..f229127b7 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 85833a54c..95a43bfa5 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 8e1df421b..1fff11286 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 a3f3715ec..4787609a2 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 e6a35cbd3..a96a7ba39 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 2b29afbc7..4668af02e 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 51e5224ea..87e2bb078 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(); }); });