Files
PaperClipAI/ui/src/components/GoalTree.tsx
T
Evyatar BluzerandAndrew Aymeloglu de837e2683 refactor(a11y): issues view toggle aria attributes and goal tree expand buttons (#1939)
## Thinking Path

Two interactive UI controls were missing WAI-ARIA attributes, so
screen-reader users couldn't perceive their state. The IssuesList
view-mode toggle already had `title` tooltips but no
`aria-label`/`aria-pressed` and its container had no `role="group"`; the
GoalTree expand/collapse chevron announced only "button". Approach:
attributes only, no logic/render changes, reusing the pattern already
shipped on the Agents page toggle. Per review feedback, GoalTree uses a
**stable** `aria-label` (`` `${goal.title} subtree` ``) with
`aria-expanded` for state, rather than a dynamic label that
double-announces state.

## Issue

_No existing tracking issue — described inline per CONTRIBUTING.md →
"Link Issues or Describe Them In-PR"._

**What happened**
Two components expose buttons with no accessible name or state, making
them unusable via screen reader: (1) the `IssuesList` view-mode toggle
doesn't convey which view is active; (2) the `GoalTree` expand/collapse
chevrons have no name and no expanded/collapsed state.

**Expected behavior**
Both controls announce their purpose and current state to assistive
technology.

**Steps to reproduce**
Enable VoiceOver, open the Issues page and Tab to the view-mode toggle,
then open the Goals page with nested goals and Tab to a tree chevron —
each control announces only "button", with no name and no
pressed/expanded state.

## What Changed

**`ui/src/components/IssuesList.tsx`** — `role="group"` +
`aria-label="View mode"` on the container; `aria-label` ("List
view"/"Board view") and `aria-pressed` on each button.

**`ui/src/components/GoalTree.tsx`** — stable `aria-label` (``
`${goal.title} subtree` ``) and `aria-expanded` on the chevron button.

2 files, ARIA attributes only, no behavioral change.

## Verification

1. Issues page → toggle announces "List view, pressed" / "Board view,
not pressed", grouped as "View mode".
2. Goals page with nested goals → each chevron announces "<goal title>
subtree" with expanded/collapsed state.
3. Manual VoiceOver pass; no visual/behavioral change for sighted users.

## Risks

Minimal — additive HTML attributes with no impact on logic, rendering,
or state. Worst case is a suboptimal announcement string, trivially
adjusted.

## Model Used

Original change human-authored by @bluzername. Two follow-up commits
(stable `aria-label` refinement; removal of a stray tooling file)
applied via maintainer edit; the refinement was drafted with Claude Opus
4.8.

## Checklist

- [x] I searched the GitHub PR list (open + recently closed) for
similar/duplicate PRs before opening — none found.

---------

Co-authored-by: Andrew Aymeloglu <aaymeloglu@gmail.com>
2026-07-01 16:32:49 -05:00

120 lines
3.1 KiB
TypeScript

import type { Goal } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { StatusBadge } from "./StatusBadge";
import { ChevronRight } from "lucide-react";
import { cn } from "../lib/utils";
import { useState } from "react";
interface GoalTreeProps {
goals: Goal[];
goalLink?: (goal: Goal) => string;
onSelect?: (goal: Goal) => void;
}
interface GoalNodeProps {
goal: Goal;
children: Goal[];
allGoals: Goal[];
depth: number;
goalLink?: (goal: Goal) => string;
onSelect?: (goal: Goal) => void;
}
function GoalNode({ goal, children, allGoals, depth, goalLink, onSelect }: GoalNodeProps) {
const [expanded, setExpanded] = useState(true);
const hasChildren = children.length > 0;
const link = goalLink?.(goal);
const inner = (
<>
{hasChildren ? (
<button
className="p-0.5"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setExpanded(!expanded);
}}
aria-label={`${goal.title} subtree`}
aria-expanded={expanded}
>
<ChevronRight
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
/>
</button>
) : (
<span className="w-4" />
)}
<span className="text-xs text-muted-foreground capitalize">{goal.level}</span>
<span className="flex-1 truncate">{goal.title}</span>
<StatusBadge status={goal.status} />
</>
);
const classes = cn(
"flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer hover:bg-accent/50",
);
return (
<div>
{link ? (
<Link
to={link}
className={cn(classes, "no-underline text-inherit")}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
>
{inner}
</Link>
) : (
<div
className={classes}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
onClick={() => onSelect?.(goal)}
>
{inner}
</div>
)}
{hasChildren && expanded && (
<div>
{children.map((child) => (
<GoalNode
key={child.id}
goal={child}
children={allGoals.filter((g) => g.parentId === child.id)}
allGoals={allGoals}
depth={depth + 1}
goalLink={goalLink}
onSelect={onSelect}
/>
))}
</div>
)}
</div>
);
}
export function GoalTree({ goals, goalLink, onSelect }: GoalTreeProps) {
const goalIds = new Set(goals.map((g) => g.id));
const roots = goals.filter((g) => !g.parentId || !goalIds.has(g.parentId));
if (goals.length === 0) {
return <p className="text-sm text-muted-foreground">No goals.</p>;
}
return (
<div className="border border-border py-1">
{roots.map((goal) => (
<GoalNode
key={goal.id}
goal={goal}
children={goals.filter((g) => g.parentId === goal.id)}
allGoals={goals}
depth={0}
goalLink={goalLink}
onSelect={onSelect}
/>
))}
</div>
);
}