Barazo default frontend barazo.forum

fix(web): coordinated line hover and improved chevron contrast

Add ThreadHoverContext so all segments of the same ancestor line
highlight together when any segment is hovered. Change chevron
color from text-border to text-muted-foreground for better contrast.

+82 -23
+1
src/components/ancestor-lines.tsx
··· 40 40 onToggle={() => onToggle(ancestor.uri)} 41 41 authorName={ancestor.authorName} 42 42 replyCount={ancestor.replyCount} 43 + ancestorUri={ancestor.uri} 43 44 opacity={opacity} 44 45 showChevron={false} 45 46 width={lineWidth}
+1
src/components/reply-branch.tsx
··· 147 147 onToggle={() => toggleCollapse(node.reply.uri)} 148 148 authorName={authorName} 149 149 replyCount={descendantCount} 150 + ancestorUri={node.reply.uri} 150 151 opacity={1} 151 152 showChevron={showChevron} 152 153 width={indentStep}
+14 -11
src/components/reply-thread.tsx
··· 13 13 import { cn } from '@/lib/utils' 14 14 import { buildReplyTree, flattenReplyTree } from '@/lib/build-reply-tree' 15 15 import { useThreadIndent } from '@/hooks/use-thread-indent' 16 + import { ThreadHoverProvider } from '@/context/thread-hover-context' 16 17 import { ReplyBranch } from './reply-branch' 17 18 18 19 interface ReplyThreadProps { ··· 58 59 <p className="text-muted-foreground">No replies yet. Be the first to respond!</p> 59 60 </div> 60 61 ) : ( 61 - <ReplyBranch 62 - nodes={tree} 63 - postNumberMap={postNumberMap} 64 - topicUri={topicUri} 65 - allReplies={allReplies} 66 - indentStep={indentStep} 67 - showChevron={showChevron} 68 - onReply={onReply} 69 - onDeleteReply={onDeleteReply} 70 - currentUserDid={currentUserDid} 71 - /> 62 + <ThreadHoverProvider> 63 + <ReplyBranch 64 + nodes={tree} 65 + postNumberMap={postNumberMap} 66 + topicUri={topicUri} 67 + allReplies={allReplies} 68 + indentStep={indentStep} 69 + showChevron={showChevron} 70 + onReply={onReply} 71 + onDeleteReply={onDeleteReply} 72 + currentUserDid={currentUserDid} 73 + /> 74 + </ThreadHoverProvider> 72 75 )} 73 76 </section> 74 77 )
+19 -9
src/components/thread-line.test.tsx
··· 2 2 import { render, screen } from '@testing-library/react' 3 3 import userEvent from '@testing-library/user-event' 4 4 import { axe } from 'vitest-axe' 5 + import { ThreadHoverProvider } from '@/context/thread-hover-context' 5 6 import { ThreadLine } from './thread-line' 6 7 8 + function renderWithProvider(ui: React.ReactElement) { 9 + return render(<ThreadHoverProvider>{ui}</ThreadHoverProvider>) 10 + } 11 + 7 12 describe('ThreadLine', () => { 8 13 const defaultProps = { 9 14 expanded: true, 10 15 onToggle: vi.fn(), 11 16 authorName: 'Alex', 12 17 replyCount: 5, 18 + ancestorUri: 'at://test/line1', 13 19 opacity: 1, 14 20 showChevron: true, 15 21 } 16 22 17 23 it('renders as a button', () => { 18 - render(<ThreadLine {...defaultProps} />) 24 + renderWithProvider(<ThreadLine {...defaultProps} />) 19 25 expect(screen.getByRole('button')).toBeInTheDocument() 20 26 }) 21 27 22 28 it('has aria-expanded matching expanded prop', () => { 23 - const { rerender } = render(<ThreadLine {...defaultProps} expanded={true} />) 29 + const { rerender } = renderWithProvider(<ThreadLine {...defaultProps} expanded={true} />) 24 30 expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true') 25 31 26 - rerender(<ThreadLine {...defaultProps} expanded={false} />) 32 + rerender( 33 + <ThreadHoverProvider> 34 + <ThreadLine {...defaultProps} expanded={false} /> 35 + </ThreadHoverProvider> 36 + ) 27 37 expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false') 28 38 }) 29 39 30 40 it('has descriptive aria-label including author and reply count', () => { 31 - render(<ThreadLine {...defaultProps} expanded={true} replyCount={5} />) 41 + renderWithProvider(<ThreadLine {...defaultProps} expanded={true} replyCount={5} />) 32 42 const label = screen.getByRole('button').getAttribute('aria-label')! 33 43 expect(label).toContain('Alex') 34 44 expect(label).toContain('5') ··· 37 47 it('calls onToggle when clicked', async () => { 38 48 const user = userEvent.setup() 39 49 const onToggle = vi.fn() 40 - render(<ThreadLine {...defaultProps} onToggle={onToggle} />) 50 + renderWithProvider(<ThreadLine {...defaultProps} onToggle={onToggle} />) 41 51 await user.click(screen.getByRole('button')) 42 52 expect(onToggle).toHaveBeenCalledTimes(1) 43 53 }) ··· 50 60 }) 51 61 52 62 it('does not render chevron icon when showChevron is false', () => { 53 - const { container } = render(<ThreadLine {...defaultProps} showChevron={false} />) 63 + const { container } = renderWithProvider(<ThreadLine {...defaultProps} showChevron={false} />) 54 64 expect(container.querySelector('svg')).not.toBeInTheDocument() 55 65 }) 56 66 57 67 it('applies opacity style to the line', () => { 58 - const { container } = render(<ThreadLine {...defaultProps} opacity={0.5} />) 68 + const { container } = renderWithProvider(<ThreadLine {...defaultProps} opacity={0.5} />) 59 69 const line = container.querySelector('[aria-hidden="true"]') 60 70 expect(line).toBeInTheDocument() 61 71 expect(line!.getAttribute('style')).toContain('opacity') 62 72 }) 63 73 64 74 it('applies width from prop', () => { 65 - const { container } = render(<ThreadLine {...defaultProps} width={22} />) 75 + const { container } = renderWithProvider(<ThreadLine {...defaultProps} width={22} />) 66 76 const button = container.querySelector('button')! 67 77 expect(button.style.width).toBe('22px') 68 78 }) 69 79 70 80 it('passes axe accessibility check', async () => { 71 - const { container } = render(<ThreadLine {...defaultProps} />) 81 + const { container } = renderWithProvider(<ThreadLine {...defaultProps} />) 72 82 const results = await axe(container) 73 83 expect(results).toHaveNoViolations() 74 84 })
+18 -3
src/components/thread-line.tsx
··· 1 1 /** 2 2 * ThreadLine - Clickable vertical line with chevron for collapsing thread branches. 3 - * Visual width: 2px. Tap target: 44px minimum for accessibility. 4 3 * Chevron: CaretDown (expanded) / CaretRight (collapsed). Hidden on mobile. 5 4 * Opacity: controlled by parent to create depth-fade effect for ancestor lines. 5 + * Hover: coordinated via ThreadHoverContext so all segments of the same 6 + * ancestor line highlight together across the full thread height. 6 7 */ 7 8 8 9 import { CaretDown, CaretRight } from '@phosphor-icons/react' 10 + import { useThreadHover } from '@/context/thread-hover-context' 9 11 10 12 interface ThreadLineProps { 11 13 expanded: boolean 12 14 onToggle: () => void 13 15 authorName: string 14 16 replyCount: number 17 + /** URI identifying which ancestor this line belongs to. Used for coordinated hover. */ 18 + ancestorUri: string 15 19 opacity?: number 16 20 showChevron?: boolean 17 21 /** Width in pixels. Matches the indent step so lines ARE the indentation. */ ··· 23 27 onToggle, 24 28 authorName, 25 29 replyCount, 30 + ancestorUri, 26 31 opacity = 1, 27 32 showChevron = true, 28 33 width = 22, 29 34 }: ThreadLineProps) { 35 + const { hoveredUri, setHovered } = useThreadHover() 36 + const isHighlighted = hoveredUri === ancestorUri 37 + 30 38 const label = expanded 31 39 ? `Collapse thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}` 32 40 : `Expand thread by ${authorName}, ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}` 33 41 42 + const chevronColor = isHighlighted ? 'text-accent-foreground' : 'text-muted-foreground' 43 + const lineColor = isHighlighted ? 'bg-accent-foreground' : 'bg-border' 44 + 34 45 return ( 35 46 <button 36 47 type="button" 37 48 onClick={onToggle} 49 + onMouseEnter={() => setHovered(ancestorUri)} 50 + onMouseLeave={() => setHovered(null)} 38 51 aria-expanded={expanded} 39 52 aria-label={label} 40 53 title={expanded ? 'Collapse thread' : 'Expand thread'} ··· 42 55 style={{ width }} 43 56 > 44 57 {showChevron && ( 45 - <span className="absolute left-1/2 top-1 -translate-x-1/2 text-border transition-colors group-hover:text-accent-foreground"> 58 + <span 59 + className={`absolute left-1/2 top-1 -translate-x-1/2 transition-colors ${chevronColor}`} 60 + > 46 61 {expanded ? ( 47 62 <CaretDown className="h-3 w-3" weight="bold" /> 48 63 ) : ( ··· 53 68 {expanded && ( 54 69 <span 55 70 aria-hidden="true" 56 - className="absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2 rounded-full bg-border transition-colors group-hover:bg-accent-foreground" 71 + className={`absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2 rounded-full transition-colors ${lineColor}`} 57 72 style={{ opacity, top: showChevron ? '1.25rem' : 0 }} 58 73 /> 59 74 )}
+29
src/context/thread-hover-context.tsx
··· 1 + 'use client' 2 + 3 + import { createContext, useContext, useState, useCallback, useMemo } from 'react' 4 + 5 + interface ThreadHoverContextValue { 6 + hoveredUri: string | null 7 + setHovered: (uri: string | null) => void 8 + } 9 + 10 + const ThreadHoverContext = createContext<ThreadHoverContextValue>({ 11 + hoveredUri: null, 12 + setHovered: () => {}, 13 + }) 14 + 15 + export function ThreadHoverProvider({ children }: { children: React.ReactNode }) { 16 + const [hoveredUri, setHoveredUri] = useState<string | null>(null) 17 + 18 + const setHovered = useCallback((uri: string | null) => { 19 + setHoveredUri(uri) 20 + }, []) 21 + 22 + const value = useMemo(() => ({ hoveredUri, setHovered }), [hoveredUri, setHovered]) 23 + 24 + return <ThreadHoverContext.Provider value={value}>{children}</ThreadHoverContext.Provider> 25 + } 26 + 27 + export function useThreadHover() { 28 + return useContext(ThreadHoverContext) 29 + }