feat(01-04): add TanStack Virtual list virtualization and keyboard navigation hook

- Install @tanstack/react-virtual for virtualized list rendering
- Create useRovingTabIndex hook in src/renderer/lib/keyboard.ts for arrow-key navigation
- Add virtualization to 6 lists: TaskQueue, ApprovalList, ActivityList, CostList, AgentRoster, MessageList
- MessageList uses measureElement for variable-height message items with auto-scroll
- All 6 lists have role="listbox" with role="option" on rows for accessibility
- All 6 lists support ArrowDown/ArrowUp/j/k/Home/End keyboard navigation
- Add 8 unit tests for useRovingTabIndex covering all key bindings and edge cases
This commit is contained in:
ZacharyZhang-NY committed 2026-03-22 02:08:43 -04:00
1 parent 7ff8dc70a2
commit d77486504d
10 files changed
+761 -175

No files matched your search

@@ -1,21 +1,72 @@
import { useRef } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import type { ApprovalRecord } from "@shared/types"
import { useT } from "../../i18n"
import { EmptyState, ListFrame, StatusPill } from "../ui"
import { useRovingTabIndex } from "../../lib/keyboard"
import { EmptyState, StatusPill } from "../ui"
export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { approvals: ApprovalRecord[]; selectedApprovalId: string | null; onSelect: (id: string) => void }) {
const t = useT()
const parentRef = useRef<HTMLDivElement>(null)
const { handleKeyDown, getTabIndex, focusedIndex } = useRovingTabIndex(approvals.length)
const virtualizer = useVirtualizer({
count: approvals.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 52,
overscan: 10,
})
if (approvals.length === 0) return <EmptyState title={t("exec.noApprovals")} />
return (
<ListFrame>
{approvals.map((approval) => (
<button key={approval.id} className={`focus-ring relative w-full border-b border-[color:var(--line)] px-5 py-4 text-left transition last:border-b-0 ${selectedApprovalId === approval.id ? "bg-transparent" : "bg-transparent hover:bg-[color:var(--panel-soft)]"}`} onClick={() => onSelect(approval.id)}>
{selectedApprovalId === approval.id ? <span className="absolute inset-y-3 left-0 w-0.5 rounded-full bg-[color:var(--warn)]" /> : null}
<div className="flex items-center justify-between gap-4">
<div className={`text-[15px] text-[color:var(--text)] ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div>
<StatusPill status={approval.state} />
</div>
</button>
))}
</ListFrame>
<div
ref={parentRef}
className="h-full overflow-auto border-y border-[color:var(--line)]"
role="listbox"
aria-label={t("exec.noApprovals")}
onKeyDown={handleKeyDown}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const approval = approvals[virtualRow.index]
return (
<div
key={approval.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
tabIndex={getTabIndex(virtualRow.index)}
role="option"
aria-selected={approval.id === selectedApprovalId}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
className={`focus-ring outline-none ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--accent)] ring-inset" : ""}`}
>
<button
className={`relative w-full border-b border-[color:var(--line)] px-5 py-4 text-left transition last:border-b-0 ${selectedApprovalId === approval.id ? "bg-transparent" : "bg-transparent hover:bg-[color:var(--panel-soft)]"}`}
onClick={() => onSelect(approval.id)}
tabIndex={-1}
>
{selectedApprovalId === approval.id ? <span className="absolute inset-y-3 left-0 w-0.5 rounded-full bg-[color:var(--warn)]" /> : null}
<div className="flex items-center justify-between gap-4">
<div className={`text-[15px] text-[color:var(--text)] ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div>
<StatusPill status={approval.state} />
</div>
</button>
</div>
)
})}
</div>
</div>
)
}