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 (