diff --git a/src/app/new/page.test.tsx b/src/app/new/page.test.tsx index 12200a3..39804d4 100644 --- a/src/app/new/page.test.tsx +++ b/src/app/new/page.test.tsx @@ -83,7 +83,8 @@ describe('NewTopicPage', () => { it('renders breadcrumbs', () => { render() - expect(screen.getByText('Home')).toBeInTheDocument() - expect(screen.getByText('New topic')).toBeInTheDocument() + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) + expect(nav).toHaveTextContent('Home') + expect(nav).toHaveTextContent('New topic') }) }) diff --git a/src/app/p/[slug]/page.test.tsx b/src/app/p/[slug]/page.test.tsx index 0364e17..0393b81 100644 --- a/src/app/p/[slug]/page.test.tsx +++ b/src/app/p/[slug]/page.test.tsx @@ -77,7 +77,8 @@ describe('PublicPage', () => { params: Promise.resolve({ slug: 'about' }), }) render(page) - expect(screen.getByText('Home')).toBeInTheDocument() + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) + expect(nav).toHaveTextContent('Home') }) it('renders JSON-LD structured data with absolute URL', async () => { diff --git a/src/app/settings/page.test.tsx b/src/app/settings/page.test.tsx index 554db56..b662add 100644 --- a/src/app/settings/page.test.tsx +++ b/src/app/settings/page.test.tsx @@ -139,7 +139,8 @@ describe('SettingsPage', () => { it('renders breadcrumbs', async () => { render() - expect(screen.getByText('Home')).toBeInTheDocument() + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) + expect(nav).toHaveTextContent('Home') }) it('loads preferences from API when authenticated', async () => { diff --git a/src/app/settings/reports/page.test.tsx b/src/app/settings/reports/page.test.tsx index 4b70708..96d6c23 100644 --- a/src/app/settings/reports/page.test.tsx +++ b/src/app/settings/reports/page.test.tsx @@ -54,7 +54,8 @@ describe('MyReportsPage', () => { it('renders breadcrumbs with link to settings', async () => { render() - expect(screen.getByText('Account settings')).toBeInTheDocument() + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) + expect(nav).toHaveTextContent('Account settings') }) it('loads and displays user reports from API', async () => { diff --git a/src/app/t/[slug]/[rkey]/page.tsx b/src/app/t/[slug]/[rkey]/page.tsx index 9ed0737..0492c75 100644 --- a/src/app/t/[slug]/[rkey]/page.tsx +++ b/src/app/t/[slug]/[rkey]/page.tsx @@ -160,10 +160,16 @@ export default async function TopicPage({ params }: TopicPageProps) { const categoryName = findCategoryName(categoriesResult.categories, topic.category) ?? topic.category + const topicSlug = slugify(topic.title) const breadcrumbItems = [ { label: 'Home', href: '/' }, { label: categoryName, href: `/c/${topic.category}` }, - { label: topic.title }, + ] + + const jsonLdItems = [ + { label: 'Home', href: '/' }, + { label: categoryName, href: `/c/${topic.category}` }, + { label: topic.title, href: `/t/${topicSlug}/${rkey}` }, ] const jsonLd = { @@ -204,7 +210,7 @@ export default async function TopicPage({ params }: TopicPageProps) { )} {/* Breadcrumbs */} - + {/* Topic + Replies + Composer (client-side for auth context) */} diff --git a/src/app/u/[handle]/page.test.tsx b/src/app/u/[handle]/page.test.tsx index 5098dca..268fb18 100644 --- a/src/app/u/[handle]/page.test.tsx +++ b/src/app/u/[handle]/page.test.tsx @@ -50,10 +50,10 @@ describe('UserProfilePage', () => { it('renders breadcrumbs', async () => { render() await waitFor(() => { - expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.getByRole('navigation', { name: /breadcrumb/i })).toBeInTheDocument() }) - const breadcrumb = screen.getByRole('navigation', { name: /breadcrumb/i }) - expect(breadcrumb).toBeInTheDocument() + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) + expect(nav).toHaveTextContent('Home') }) it('renders profile sections', async () => { diff --git a/src/components/breadcrumbs.test.tsx b/src/components/breadcrumbs.test.tsx index cbea09e..2a33bf5 100644 --- a/src/components/breadcrumbs.test.tsx +++ b/src/components/breadcrumbs.test.tsx @@ -7,26 +7,38 @@ describe('Breadcrumbs', () => { const items = [ { label: 'Home', href: '/' }, { label: 'Development', href: '/c/development' }, - { label: 'Frontend', href: '/c/development/frontend' }, + { label: 'Current Page' }, ] - it('renders all breadcrumb items', () => { + it('renders all breadcrumb items in desktop layout', () => { render() - expect(screen.getByText('Home')).toBeInTheDocument() - expect(screen.getByText('Development')).toBeInTheDocument() - expect(screen.getByText('Frontend')).toBeInTheDocument() + const desktopList = screen.getByRole('list') + expect(desktopList).toHaveTextContent('Home') + expect(desktopList).toHaveTextContent('Development') + expect(desktopList).toHaveTextContent('Current Page') }) - it('renders links for non-current items', () => { + it('renders links for non-last items in desktop layout', () => { render() - const homeLink = screen.getByRole('link', { name: 'Home' }) - expect(homeLink).toHaveAttribute('href', '/') + const homeLinks = screen.getAllByRole('link', { name: 'Home' }) + expect(homeLinks.length).toBeGreaterThanOrEqual(1) + expect(homeLinks[0]).toHaveAttribute('href', '/') }) - it('marks last item as current page', () => { + it('renders mobile back-link with last navigable item', () => { render() - const current = screen.getByText('Frontend') - expect(current).toHaveAttribute('aria-current', 'page') + const devLinks = screen.getAllByRole('link', { name: /Development/i }) + const mobileLink = devLinks.find((el) => el.classList.contains('md:hidden')) + expect(mobileLink).toBeDefined() + expect(mobileLink).toHaveAttribute('href', '/c/development') + }) + + it('renders CaretLeft icon with aria-hidden in mobile back-link', () => { + const { container } = render() + const svgs = container.querySelectorAll('svg') + expect(svgs.length).toBeGreaterThan(0) + const caretIcon = svgs[0] + expect(caretIcon).toHaveAttribute('aria-hidden', 'true') }) it('has accessible navigation landmark', () => { @@ -34,19 +46,51 @@ describe('Breadcrumbs', () => { expect(screen.getByRole('navigation', { name: /breadcrumb/i })).toBeInTheDocument() }) - it('renders separator between items', () => { + it('renders separator between items in desktop layout', () => { render() const separators = screen.getAllByText('/') expect(separators).toHaveLength(2) }) - it('includes JSON-LD structured data', () => { + it('includes JSON-LD structured data from items', () => { const { container } = render() const script = container.querySelector('script[type="application/ld+json"]') expect(script).toBeInTheDocument() const jsonLd = JSON.parse(script!.textContent!) expect(jsonLd['@type']).toBe('BreadcrumbList') + // Only items with href are included in JSON-LD + expect(jsonLd.itemListElement).toHaveLength(2) + expect(jsonLd.itemListElement[0].name).toBe('Home') + expect(jsonLd.itemListElement[1].name).toBe('Development') + }) + + it('uses jsonLdItems for JSON-LD when provided', () => { + const jsonLdItems = [ + { label: 'Home', href: '/' }, + { label: 'Development', href: '/c/development' }, + { label: 'My Topic Title', href: '/t/my-topic/abc123' }, + ] + const visualItems = [ + { label: 'Home', href: '/' }, + { label: 'Development', href: '/c/development' }, + ] + const { container } = render() + const script = container.querySelector('script[type="application/ld+json"]') + const jsonLd = JSON.parse(script!.textContent!) expect(jsonLd.itemListElement).toHaveLength(3) + expect(jsonLd.itemListElement[2].name).toBe('My Topic Title') + }) + + it('desktop layout is hidden on mobile via hidden md:flex', () => { + render() + const desktopList = screen.getByRole('list') + expect(desktopList).toHaveClass('hidden') + expect(desktopList).toHaveClass('md:flex') + }) + + it('returns null for empty items', () => { + const { container } = render() + expect(container.querySelector('nav')).not.toBeInTheDocument() }) it('passes axe accessibility check', async () => { diff --git a/src/components/breadcrumbs.tsx b/src/components/breadcrumbs.tsx index 0032ba8..fbc4db2 100644 --- a/src/components/breadcrumbs.tsx +++ b/src/components/breadcrumbs.tsx @@ -1,10 +1,13 @@ /** * Breadcrumbs component with JSON-LD structured data. - * WCAG 2.2 AA: nav landmark, aria-current, semantic list. + * WCAG 2.2 AA: nav landmark, semantic list, 44px mobile touch target. + * Mobile: collapses to single parent back-link. + * Desktop: full breadcrumb trail. * @see https://schema.org/BreadcrumbList */ import Link from 'next/link' +import { CaretLeft } from '@phosphor-icons/react/dist/ssr' export interface BreadcrumbItem { label: string @@ -13,15 +16,18 @@ export interface BreadcrumbItem { interface BreadcrumbsProps { items: BreadcrumbItem[] + /** When provided, JSON-LD uses these instead of `items`. Lets pages keep full path in structured data while showing fewer visual breadcrumbs. */ + jsonLdItems?: BreadcrumbItem[] } -export function Breadcrumbs({ items }: BreadcrumbsProps) { +export function Breadcrumbs({ items, jsonLdItems }: BreadcrumbsProps) { if (items.length === 0) return null + const jsonLdSource = jsonLdItems ?? items const jsonLd = { '@context': 'https://schema.org', '@type': 'BreadcrumbList', - itemListElement: items + itemListElement: jsonLdSource .filter((item) => item.href) .map((item, index) => ({ '@type': 'ListItem', @@ -31,13 +37,29 @@ export function Breadcrumbs({ items }: BreadcrumbsProps) { })), } + // Last item with an href = parent link for mobile back-link + const parentItem = [...items].reverse().find((item) => item.href) + return (