Skip to content
Draft
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
40 changes: 40 additions & 0 deletions docs/frontend-ui-audit-2026-08-07/KeyboardOperability.md
Original file line number Diff line number Diff line change
@@ -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 `<button>` semantics; no new shared component was introduced (only 2 collapse-row sites exist — below the ≥3 abstraction threshold).

## D4 — Accessibility (keyboard operability)

| Line | Element | Verdict | Reason | Suggested change (applied) |
| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/features/CodeViewer/components/CollapseRow.tsx` L31 | `div.split-row-collapse` + `onClick` (expand collapsed lines) | **fix** | Multi-column split-diff row layout (left pane / gutter / right pane) — DS `Button` does not cover this layout, so the `div` stays but must carry full keyboard semantics | Added `role="button"`, `tabIndex={0}`, Enter/Space via `createKeyboardActivationHandler`, `aria-expanded={false}`, `aria-label="Expand N unchanged lines"`; `:focus-visible` outline in `ModernSplitDiff.scss` |
| `src/engines/GitWorkflow/GitHubDiff/DiffRow.tsx` L233 (`CollapsedSection`, div at L236) | `div.diff-collapsed-section` + `onClick` (show/hide unchanged lines) | **fix** | Diff-row cluster exception (engines rule allows custom element for diff rows) but keyboard semantics were missing entirely | Added `role="button"`, `tabIndex={0}`, Enter/Space handler, `aria-expanded={isExpanded}`; visible text ("Show/Hide N unchanged lines") is the accessible name; `:focus-visible` outline in `index.scss` |
| `src/engines/ChatPanel/components/DebugJsonViewer/index.tsx` L105 (row div at L107) | `div.json-node__row` + `onClick` (JSON tree node toggle) | **fix** | Tree-row layout; only expandable nodes are interactive — primitive rows keep a no-op `onClick` and must stay inert (no phantom tab stops) | `role="button"` / `tabIndex={0}` / `aria-expanded` / Enter-Space handler applied **conditionally** (`isExpandable` only); key/value text is the accessible name; `:focus-visible` outline in `index.scss` |
| `src/components/SearchInput/index.tsx` L208 (chevron div at L213) | `div` + `onClick` (expand/collapse replace row chevron) | **fix** | Inside `src/components/**` native elements are allowed — the `div` had no justification; sibling option toggles in the same file are already native buttons | Promoted to `<button type="button">` with `aria-expanded={expanded}`, `aria-label`/`title` reusing existing `tooltips.replace` i18n key, focus-visible ring (`ring-primary-6/30` house style) |
| `src/components/DateRangeSelector/index.tsx` L94 (trigger div at L96) | `div` + `onClick` (date-range picker trigger) | **fix** | Same as above — DS-internal component must use a semantically correct native trigger | Promoted to `<button type="button">` with `aria-expanded={isOpen}`; visible date-range text is the accessible name; focus-visible ring added to the trigger classes |
| `src/engines/Simulator/components/ListPanelSidebar/index.tsx` L145 (`DefaultListItem` row div at L149) | `div` + `onClick` (list row select) | **fix** | Row hosts an interactive `Checkbox` child — wrapping in a native `<button>` would nest interactive controls (invalid HTML), so the `div` stays with full keyboard semantics instead | Added `role="button"`, `tabIndex={0}`, Enter/Space handler (bypasses the mouse-only checkbox-exclusion guard), inset focus-visible ring |
| `src/modules/shared/layouts/blocks/BrowseCard.tsx` L89 (actionButton branch div at L91) | `div` + `onClick` + nested `actionButton` (card primary click) | **fix** | Primary card action was mouse-only whenever `actionButton` was present; nesting a `<button>` inside a `<button>` is invalid, so the primary action becomes a stretched **sibling** overlay button | Overlay `<button aria-label={title}>` with `absolute inset-0`; `actionButton` wrapper gets `relative z-10` so it sits above the overlay and receives its own clicks; both branches get focus-visible rings. No callers exist yet (barrel export only), so no behavior regression surface |

No click-outside / modal-backdrop hits were among these 7 sites — none needed a keep-with-reason verdict in this batch.

## D5 — Visual patterns observed

- "Collapsed unchanged-lines row" pattern: `CollapseRow.tsx` + `DiffRow.tsx CollapsedSection` — **2 occurrences**, below the ≥3 abstraction threshold. Watch-list only; both now share `createKeyboardActivationHandler` for behavior.

## Out-of-scope hits noticed (not fixed — follow-up candidates)

| Where | Element | Note |
| --------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DiffRow.tsx` L93 / L175 / L199 | `UnifiedDiffRow` / `SplitDiffRowComponent` per-line `div` + `onClick` (line highlight) | Per-line `tabIndex` would create hundreds of tab stops; proper fix is container-level roving tabindex / grid keyboard navigation — separate design, out of this PR's scope |
| `ListPanelSidebar/index.tsx` L269 | `renderItem` custom-render wrapper `div` + `onClick` | Same row-select concern for the custom-render branch; the wrapper delegates to caller-rendered content, needs per-caller audit |

## Summary

- **7 fixes** applied (all 7 flagged sites)
- **0 keep-with-reason** (no backdrop/decorative hits in this batch)
- **0 abstract candidates** (collapse pattern at 2 occurrences — watch-list)
- Keyboard handler logic covered by `src/util/dom/__tests__/keyboardActivation.test.ts` (extended with Space-activation + preventDefault cases in this PR)
11 changes: 8 additions & 3 deletions src/components/DateRangeSelector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,21 @@ const DateRangeSelector: React.FC<DateRangeSelectorProps> = ({
};

const defaultClassName =
"flex items-center border w-fit border-border-2 border-solid rounded-[8px] px-3 py-[1px] gap-1 text-text-2 text-[14px] bg-bg-3 cursor-pointer hover:bg-fill-2 transition-colors";
"flex items-center border w-fit border-border-2 border-solid rounded-[8px] px-3 py-[1px] gap-1 text-text-2 text-[14px] bg-bg-3 cursor-pointer hover:bg-fill-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-6/30";

return (
<div className={`date-range-selector ${className}`}>
<div className={`${defaultClassName}`} onClick={() => setIsOpen(!isOpen)}>
<button
type="button"
className={`${defaultClassName}`}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
>
<Calendar className="text-[14px] text-text-2" size={14} />
<span className="text-[14px] font-[400] text-text-2">
{dateRange || placeholder}
</span>
</div>
</button>

{isOpen && (
<div className="date-range-selector__picker">
Expand Down
11 changes: 9 additions & 2 deletions src/components/SearchInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,20 @@ export const SearchInput: React.FC<SearchInputProps> = memo(
<div className={`${containerClass} ${className}`}>
{/* Expand/collapse chevron */}
{onExpandToggle && !hideChevron && (
<div onClick={onExpandToggle} className={buttonClass}>
<button
type="button"
onClick={onExpandToggle}
className={`${buttonClass} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-6/30`}
aria-expanded={expanded}
aria-label={t("tooltips.replace")}
title={t("tooltips.replace")}
>
{expanded ? (
<ChevronDown size={iconSize} />
) : (
<ChevronRight size={iconSize} />
)}
</div>
</button>
)}

{/* Search input with inline options */}
Expand Down
5 changes: 5 additions & 0 deletions src/engines/ChatPanel/components/DebugJsonViewer/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
&:hover {
background: var(--color-fill-2);
}

&:focus-visible {
outline: 2px solid var(--color-primary-6);
outline-offset: -2px;
}
}

&__arrow {
Expand Down
23 changes: 22 additions & 1 deletion src/engines/ChatPanel/components/DebugJsonViewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ============================================
Expand Down Expand Up @@ -35,6 +37,21 @@ const JsonNode: React.FC<JsonNodeProps> = 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) {
Expand Down Expand Up @@ -104,7 +121,11 @@ const JsonNode: React.FC<JsonNodeProps> = memo(

return (
<div className="json-node" style={{ paddingLeft: depth * 16 }}>
<div className="json-node__row" onClick={toggleExpand}>
<div
className="json-node__row"
onClick={toggleExpand}
{...interactiveRowProps}
>
{/* Expand/Collapse Arrow */}
{isExpandable &&
(isExpanded ? (
Expand Down
11 changes: 10 additions & 1 deletion src/engines/GitWorkflow/GitHubDiff/DiffRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ============================================
Expand Down Expand Up @@ -233,7 +235,14 @@ interface CollapsedSectionProps {
export const CollapsedSection: React.FC<CollapsedSectionProps> = React.memo(
({ lineCount, isExpanded, onToggle }) => {
return (
<div className="diff-collapsed-section" onClick={onToggle}>
<div
className="diff-collapsed-section"
onClick={onToggle}
onKeyDown={createKeyboardActivationHandler(onToggle)}
role="button"
tabIndex={0}
aria-expanded={isExpanded}
>
<div className="diff-collapsed-icon">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/engines/GitWorkflow/GitHubDiff/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 10 additions & 1 deletion src/engines/Simulator/components/ListPanelSidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -140,19 +141,27 @@ const DefaultListItem: React.FC<DefaultItemProps> = ({
[onCheckChange]
);

const handleKeyDown = useMemo(
() => createKeyboardActivationHandler(onClick),
[onClick]
);

// Build full path for display
const displayPath = item.secondaryText
? `${item.secondaryText}/${item.name}`
: item.name;

return (
<div
className={`flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2 transition-colors ${
className={`flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-6/30 ${
isSelected
? `${SURFACE_TOKENS.selected} text-primary-6 ${SURFACE_TOKENS.selectedHover}`
: `text-text-1 ${SURFACE_TOKENS.hover}`
}`}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
>
{/* Checkbox (optional) */}
{showCheckbox && (
Expand Down
5 changes: 5 additions & 0 deletions src/features/CodeViewer/ModernSplitDiff.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion src/features/CodeViewer/components/CollapseRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,7 +30,15 @@ export const CollapseRow: React.FC<CollapseRowProps> = ({
: FoldVertical;

return (
<div className="split-row split-row-collapse" onClick={onExpand}>
<div
className="split-row split-row-collapse"
onClick={onExpand}
onKeyDown={createKeyboardActivationHandler(onExpand)}
role="button"
tabIndex={0}
aria-expanded={false}
aria-label={`Expand ${collapsedSection.collapsedCount} unchanged lines`}
>
{/* Left pane */}
<div className="split-row-pane split-row-pane-left split-row-context">
<div className="split-row-content collapse-placeholder" />
Expand Down
42 changes: 28 additions & 14 deletions src/modules/shared/layouts/blocks/BrowseCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div>` so the nested button is valid HTML.
* and the card's primary click target becomes a stretched sibling `<button>`
* (absolutely positioned overlay) so the action button is never nested inside
* another button and both stay keyboard-operable.
*/
import { ChevronRight } from "lucide-react";
import React from "react";
Expand Down Expand Up @@ -52,8 +54,10 @@ const BrowseCard: React.FC<BrowseCardProps> = ({
className = "",
}) => {
const baseClasses = `group/card relative flex w-full items-start gap-3 rounded-lg bg-fill-2 px-4 py-3 text-left transition-colors hover:bg-fill-2 ${className}`;
const focusRingClasses =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-6/30";

const content = (
const mainContent = (
<>
{icon && <div className="shrink-0">{icon}</div>}
<div className="min-w-0 flex-1">
Expand All @@ -75,28 +79,38 @@ const BrowseCard: React.FC<BrowseCardProps> = ({
)}
{meta && <div className="mt-1.5">{meta}</div>}
</div>
{actionButton ? (
<div className="shrink-0">{actionButton}</div>
) : (
<ChevronRight
size={14}
className="absolute right-4 top-1/2 -translate-y-1/2 text-text-2 opacity-0 transition-opacity group-hover/card:opacity-100"
/>
)}
</>
);

if (actionButton) {
return (
<div className={baseClasses} onClick={onClick} role="group">
{content}
<div className={baseClasses} role="group">
{onClick && (
<button
type="button"
onClick={onClick}
aria-label={title}
className={`absolute inset-0 cursor-pointer rounded-lg ${focusRingClasses}`}
/>
)}
{mainContent}
{/* Positioned above the stretched overlay so it receives its own clicks */}
<div className="relative z-10 shrink-0">{actionButton}</div>
</div>
);
}

return (
<button type="button" className={baseClasses} onClick={onClick}>
{content}
<button
type="button"
className={`${baseClasses} ${focusRingClasses}`}
onClick={onClick}
>
{mainContent}
<ChevronRight
size={14}
className="absolute right-4 top-1/2 -translate-y-1/2 text-text-2 opacity-0 transition-opacity group-hover/card:opacity-100"
/>
</button>
);
};
Expand Down
18 changes: 14 additions & 4 deletions src/util/dom/__tests__/keyboardActivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Expand Down
Loading