mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +02:00
## 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>
120 lines
3.1 KiB
TypeScript
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>
|
|
);
|
|
}
|