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
56 changes: 56 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,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 `<button>` | keep with reason | This DS-internal compound trigger owns a third-party range picker and has no covering DS button shape. | — |
| `SearchInput/index.tsx:213` | native chevron `<button>` | keep with reason | Icon-only disclosure lives inside the DS component and reuses its compact header-control sizing. | — |
| `DebugJsonViewer/index.tsx:110` | interactive tree-row `<div>` | keep with reason | A JSON tree row has key/value layout and is interactive only for expandable values; a generic DS button does not cover the tree-row shape. | — |
| `GitHubDiff/DiffRow.tsx:238` | collapsed diff-row `<div>` | keep with reason | The diff engine uses a specialized full-width row/gutter layout; DS Button cannot host it. | — |
| `ListPanelSidebar/index.tsx:153` | selectable list-row `<div>` | keep with reason | The row may contain a separately interactive Checkbox, so a native outer button would create invalid nested controls. | — |
| `CollapseRow.tsx:33` | split-diff collapse-row `<div>` | keep with reason | The control spans left pane, center gutter, and right pane; DS Button cannot represent this grid. | — |
| `BrowseCard.tsx:88` | stretched sibling `<button>` | keep with reason | The absolute overlay is the primary card hit area while the action button remains a non-nested sibling; DS Button does not cover a stretched overlay. | — |

## D2 — Arbitrary Tailwind Value vs Token

No new arbitrary CSS-variable or raw-color classes were added. All new focus treatment uses existing `ring-primary-6/30`, `outline-none`, and layout tokens.

## D3 — Hardcoded Sizes / Colors

No new hardcoded pixel sizes or raw colors were added. Existing `text-[14px]`, `rounded-[8px]`, and similar values in the touched components predate this accessibility change and were not expanded.

## D4 — Accessibility

| Line | Element | Verdict | Reason | Suggested change |
| -------------------------------- | ----------------------------------------- | ------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `CollapseRow.tsx:33` | collapsed split-diff row | fix | Mouse-only expansion had no focus stop, keyboard path, name, or expanded state. | Added `role="button"`, `tabIndex`, Enter/Space, `aria-expanded`, an explicit label, and focus-visible outline. |
| `GitHubDiff/DiffRow.tsx:238` | collapsed unchanged-lines row | fix | Mouse-only show/hide control. | Added button role, `tabIndex`, Enter/Space, `aria-expanded`, and focus-visible outline; visible text supplies the name. |
| `DebugJsonViewer/index.tsx:110` | expandable JSON node row | fix | Expandable nodes had only click activation; primitive rows carried a no-op handler. | Interactive props and click/keyboard handlers are now conditional on expandability; primitive rows stay fully inert. |
| `SearchInput/index.tsx:213` | replace-row chevron | fix | Non-semantic icon click target had no accessible name or state. | Promoted to native button with localized label/title, `aria-expanded`, and focus ring. |
| `DateRangeSelector/index.tsx:95` | date-range trigger | fix | Non-semantic trigger had no keyboard activation or expanded state. | Promoted to native button with `aria-expanded`; visible date text supplies the name. |
| `ListPanelSidebar/index.tsx:153` | default list item | fix | Row selection was mouse-only. | Added button role, `tabIndex`, Enter/Space, and inset focus ring while preserving the child Checkbox path. |
| `BrowseCard.tsx:88` | card primary action with secondary action | fix | The action-button branch made the primary card action mouse-only; nesting buttons would be invalid. | Added a named stretched sibling button and lifted the secondary action above it; both branches have focus-visible treatment. |

## D5 — Visual Patterns Observed

- Collapsed unchanged-lines row: `CollapseRow.tsx` and `DiffRow.tsx` — two occurrences, below the three-site abstraction threshold.
- Focus-visible treatment consistently uses the existing primary ring token; no new visual primitive is needed.

## Next-refactor candidates

- Per-line diff selection needs a container-level roving-tabindex/grid design; adding hundreds of individual tab stops would be worse.
- The custom-render branch of `ListPanelSidebar` needs a caller-aware keyboard contract and remains outside this focused batch.

## Summary

- 7 accessibility fixes
- 7 raw-element decisions kept with documented design-system reasons
- 0 arbitrary-token or new hardcoded-size findings
- 0 abstraction candidates
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
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(".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);
});
});
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={isExpandable ? toggleExpand : undefined}
{...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
Loading
Loading