diff --git a/src/components/ancestor-lines.tsx b/src/components/ancestor-lines.tsx index ff4ea2d..a94b975 100644 --- a/src/components/ancestor-lines.tsx +++ b/src/components/ancestor-lines.tsx @@ -40,6 +40,7 @@ export function AncestorLines({ ancestors, onToggle, lineWidth }: AncestorLinesP onToggle={() => onToggle(ancestor.uri)} authorName={ancestor.authorName} replyCount={ancestor.replyCount} + ancestorUri={ancestor.uri} opacity={opacity} showChevron={false} width={lineWidth} diff --git a/src/components/reply-branch.tsx b/src/components/reply-branch.tsx index 6cf0353..0e6ef91 100644 --- a/src/components/reply-branch.tsx +++ b/src/components/reply-branch.tsx @@ -147,6 +147,7 @@ export function ReplyBranch({ onToggle={() => toggleCollapse(node.reply.uri)} authorName={authorName} replyCount={descendantCount} + ancestorUri={node.reply.uri} opacity={1} showChevron={showChevron} width={indentStep} diff --git a/src/components/reply-thread.tsx b/src/components/reply-thread.tsx index 13f2cab..49ce0cb 100644 --- a/src/components/reply-thread.tsx +++ b/src/components/reply-thread.tsx @@ -13,6 +13,7 @@ import type { Reply } from '@/lib/api/types' import { cn } from '@/lib/utils' import { buildReplyTree, flattenReplyTree } from '@/lib/build-reply-tree' import { useThreadIndent } from '@/hooks/use-thread-indent' +import { ThreadHoverProvider } from '@/context/thread-hover-context' import { ReplyBranch } from './reply-branch' interface ReplyThreadProps { @@ -58,17 +59,19 @@ export function ReplyThread({

No replies yet. Be the first to respond!

) : ( - + + + )} ) diff --git a/src/components/thread-line.test.tsx b/src/components/thread-line.test.tsx index 9840b6d..e1c876d 100644 --- a/src/components/thread-line.test.tsx +++ b/src/components/thread-line.test.tsx @@ -2,33 +2,43 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' +import { ThreadHoverProvider } from '@/context/thread-hover-context' import { ThreadLine } from './thread-line' +function renderWithProvider(ui: React.ReactElement) { + return render({ui}) +} + describe('ThreadLine', () => { const defaultProps = { expanded: true, onToggle: vi.fn(), authorName: 'Alex', replyCount: 5, + ancestorUri: 'at://test/line1', opacity: 1, showChevron: true, } it('renders as a button', () => { - render() + renderWithProvider() expect(screen.getByRole('button')).toBeInTheDocument() }) it('has aria-expanded matching expanded prop', () => { - const { rerender } = render() + const { rerender } = renderWithProvider() expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true') - rerender() + rerender( + + + + ) expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false') }) it('has descriptive aria-label including author and reply count', () => { - render() + renderWithProvider() const label = screen.getByRole('button').getAttribute('aria-label')! expect(label).toContain('Alex') expect(label).toContain('5') @@ -37,7 +47,7 @@ describe('ThreadLine', () => { it('calls onToggle when clicked', async () => { const user = userEvent.setup() const onToggle = vi.fn() - render() + renderWithProvider() await user.click(screen.getByRole('button')) expect(onToggle).toHaveBeenCalledTimes(1) }) @@ -50,25 +60,25 @@ describe('ThreadLine', () => { }) it('does not render chevron icon when showChevron is false', () => { - const { container } = render() + const { container } = renderWithProvider() expect(container.querySelector('svg')).not.toBeInTheDocument() }) it('applies opacity style to the line', () => { - const { container } = render() + const { container } = renderWithProvider() const line = container.querySelector('[aria-hidden="true"]') expect(line).toBeInTheDocument() expect(line!.getAttribute('style')).toContain('opacity') }) it('applies width from prop', () => { - const { container } = render() + const { container } = renderWithProvider() const button = container.querySelector('button')! expect(button.style.width).toBe('22px') }) it('passes axe accessibility check', async () => { - const { container } = render() + const { container } = renderWithProvider() const results = await axe(container) expect(results).toHaveNoViolations() }) diff --git a/src/components/thread-line.tsx b/src/components/thread-line.tsx index 3499a4b..f4a0d84 100644 --- a/src/components/thread-line.tsx +++ b/src/components/thread-line.tsx @@ -1,17 +1,21 @@ /** * ThreadLine - Clickable vertical line with chevron for collapsing thread branches. - * Visual width: 2px. Tap target: 44px minimum for accessibility. * Chevron: CaretDown (expanded) / CaretRight (collapsed). Hidden on mobile. * Opacity: controlled by parent to create depth-fade effect for ancestor lines. + * Hover: coordinated via ThreadHoverContext so all segments of the same + * ancestor line highlight together across the full thread height. */ import { CaretDown, CaretRight } from '@phosphor-icons/react' +import { useThreadHover } from '@/context/thread-hover-context' interface ThreadLineProps { expanded: boolean onToggle: () => void authorName: string replyCount: number + /** URI identifying which ancestor this line belongs to. Used for coordinated hover. */ + ancestorUri: string opacity?: number showChevron?: boolean /** Width in pixels. Matches the indent step so lines ARE the indentation. */ @@ -23,18 +27,27 @@ export function ThreadLine({ onToggle, authorName, replyCount, + ancestorUri, opacity = 1, showChevron = true, width = 22, }: ThreadLineProps) { + const { hoveredUri, setHovered } = useThreadHover() + const isHighlighted = hoveredUri === ancestorUri + const label = expanded ? `Collapse thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}` : `Expand thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}` + const chevronColor = isHighlighted ? 'text-accent-foreground' : 'text-muted-foreground' + const lineColor = isHighlighted ? 'bg-accent-foreground' : 'bg-border' + return (