diff --git a/apps/next/src/app/(app)/spoons/page.tsx b/apps/next/src/app/(app)/spoons/page.tsx index 361a63e..e126143 100644 --- a/apps/next/src/app/(app)/spoons/page.tsx +++ b/apps/next/src/app/(app)/spoons/page.tsx @@ -1,7 +1,6 @@ 'use client'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge'; import { ListSkeleton } from '@/components/ui/list-skeleton'; import { useQuery } from 'convex/react'; @@ -33,7 +32,6 @@ const formatDate = (value?: number) => : 'Never'; const SpoonsPage = () => { - const router = useRouter(); const spoons = useQuery(api.spoons.listMineWithState, {}); const threads = useQuery(api.threads.listMine, { limit: 100 }); const spoonList = spoons ?? []; @@ -131,22 +129,12 @@ const SpoonsPage = () => { return ( router.push(href)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - router.push(href); - } - }} + className='hover:bg-muted/50 relative cursor-pointer' > event.stopPropagation()} + className='group inline-flex min-w-0 flex-col after:absolute after:inset-0 after:content-[""]' > {spoon.name} @@ -190,10 +178,7 @@ const SpoonsPage = () => { {formatDate(spoon.lastCheckedAt)} ) : null} {thread.latestJobPullRequestUrl ? ( @@ -381,7 +374,6 @@ const ThreadsPage = () => { href={thread.latestJobPullRequestUrl} target='_blank' rel='noreferrer' - onClick={(event) => event.stopPropagation()} > PR diff --git a/apps/next/tests/component/list-a11y.test.tsx b/apps/next/tests/component/list-a11y.test.tsx new file mode 100644 index 0000000..598eaac --- /dev/null +++ b/apps/next/tests/component/list-a11y.test.tsx @@ -0,0 +1,84 @@ +import { cleanup, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import SpoonsPage from '../../src/app/(app)/spoons/page'; + +const { mockUseQuery, mockPush } = vi.hoisted(() => ({ + mockUseQuery: vi.fn(), + mockPush: vi.fn(), +})); + +vi.mock('convex/react', () => ({ + useConvexAuth: () => ({ isAuthenticated: false }), + useMutation: vi.fn(), + useQuery: mockUseQuery, +})); + +vi.mock('next/navigation', () => ({ + useParams: vi.fn(), + useRouter: () => ({ push: mockPush, replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +const spoon = { + _id: 'spoon-1', + name: 'useSend', + upstreamOwner: 'acme', + upstreamRepo: 'app', + forkOwner: 'me', + forkRepo: 'app', + status: 'active', + syncStatus: undefined, + syncCadence: 'daily', + effectiveUpstreamAheadBy: 0, + rawUpstreamAheadBy: 0, + forkAheadBy: 0, + ignoredUpstreamCount: 0, + lastCheckedAt: undefined, +}; + +describe('spoon list accessibility', () => { + afterEach(() => { + cleanup(); + mockUseQuery.mockReset(); + mockPush.mockReset(); + }); + + it('does not nest interactive elements inside a role="link" container', () => { + mockUseQuery.mockImplementation((_query, args) => { + // threads list passes a limit; spoons list passes {}. + if (args && 'limit' in args) return []; + return [spoon]; + }); + + const { container } = render(); + + // Invalid ARIA nested-interactive pattern must be gone. + expect(container.querySelector('[role="link"]')).toBeNull(); + }); + + it('keeps a primary navigation link and a secondary "Open" link to the spoon detail', () => { + mockUseQuery.mockImplementation((_query, args) => { + if (args && 'limit' in args) return []; + return [spoon]; + }); + + render(); + + const href = '/spoons/spoon-1'; + + // Primary link: the spoon name navigates to the detail route. + const nameLink = screen.getByRole('link', { name: /useSend/ }); + expect(nameLink).toHaveAttribute('href', href); + + // Secondary action: the "Open" link still exists and points to the detail. + const openLink = screen.getByRole('link', { name: /open/i }); + expect(openLink).toHaveAttribute('href', href); + + // Both real anchors point at the destination; no simulated role="link". + const links = screen.getAllByRole('link'); + for (const link of links.filter((el) => within(el).queryByText('useSend'))) { + expect(link).toHaveAttribute('href', href); + } + }); +});