Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(breadcrumbs): collapse to parent back-link on mobile (#174)

* feat(breadcrumbs): collapse to parent back-link on mobile

Mobile breadcrumbs wrapped across multiple lines on narrow screens.
Now shows a single parent back-link with CaretLeft icon (44px touch
target) on mobile, full trail on desktop. Removes redundant topic
title from visual breadcrumbs on thread pages while preserving it
in JSON-LD structured data via new jsonLdItems prop.

* fix(breadcrumbs): update tests for dual mobile/desktop render

Tests using getByText('Home') failed in CI because the mobile
back-link and desktop breadcrumb list both render the same text.
Switch to querying within the nav landmark or using getAllByRole.

authored by

Guido X Jansen and committed by
GitHub
c42e54dc 1bd3a659

+104 -33
+3 -2
src/app/new/page.test.tsx
··· 83 83 84 84 it('renders breadcrumbs', () => { 85 85 render(<NewTopicPage />) 86 - expect(screen.getByText('Home')).toBeInTheDocument() 87 - expect(screen.getByText('New topic')).toBeInTheDocument() 86 + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) 87 + expect(nav).toHaveTextContent('Home') 88 + expect(nav).toHaveTextContent('New topic') 88 89 }) 89 90 })
+2 -1
src/app/p/[slug]/page.test.tsx
··· 77 77 params: Promise.resolve({ slug: 'about' }), 78 78 }) 79 79 render(page) 80 - expect(screen.getByText('Home')).toBeInTheDocument() 80 + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) 81 + expect(nav).toHaveTextContent('Home') 81 82 }) 82 83 83 84 it('renders JSON-LD structured data with absolute URL', async () => {
+2 -1
src/app/settings/page.test.tsx
··· 139 139 140 140 it('renders breadcrumbs', async () => { 141 141 render(<SettingsPage />) 142 - expect(screen.getByText('Home')).toBeInTheDocument() 142 + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) 143 + expect(nav).toHaveTextContent('Home') 143 144 }) 144 145 145 146 it('loads preferences from API when authenticated', async () => {
+2 -1
src/app/settings/reports/page.test.tsx
··· 54 54 55 55 it('renders breadcrumbs with link to settings', async () => { 56 56 render(<MyReportsPage />) 57 - expect(screen.getByText('Account settings')).toBeInTheDocument() 57 + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) 58 + expect(nav).toHaveTextContent('Account settings') 58 59 }) 59 60 60 61 it('loads and displays user reports from API', async () => {
+8 -2
src/app/t/[slug]/[rkey]/page.tsx
··· 160 160 const categoryName = 161 161 findCategoryName(categoriesResult.categories, topic.category) ?? topic.category 162 162 163 + const topicSlug = slugify(topic.title) 163 164 const breadcrumbItems = [ 164 165 { label: 'Home', href: '/' }, 165 166 { label: categoryName, href: `/c/${topic.category}` }, 166 - { label: topic.title }, 167 + ] 168 + 169 + const jsonLdItems = [ 170 + { label: 'Home', href: '/' }, 171 + { label: categoryName, href: `/c/${topic.category}` }, 172 + { label: topic.title, href: `/t/${topicSlug}/${rkey}` }, 167 173 ] 168 174 169 175 const jsonLd = { ··· 204 210 )} 205 211 206 212 {/* Breadcrumbs */} 207 - <Breadcrumbs items={breadcrumbItems} /> 213 + <Breadcrumbs items={breadcrumbItems} jsonLdItems={jsonLdItems} /> 208 214 209 215 {/* Topic + Replies + Composer (client-side for auth context) */} 210 216 <TopicDetailClient topic={topic} replies={repliesResult.replies} />
+3 -3
src/app/u/[handle]/page.test.tsx
··· 50 50 it('renders breadcrumbs', async () => { 51 51 render(<UserProfilePage params={{ handle: 'jay.bsky.team' }} />) 52 52 await waitFor(() => { 53 - expect(screen.getByText('Home')).toBeInTheDocument() 53 + expect(screen.getByRole('navigation', { name: /breadcrumb/i })).toBeInTheDocument() 54 54 }) 55 - const breadcrumb = screen.getByRole('navigation', { name: /breadcrumb/i }) 56 - expect(breadcrumb).toBeInTheDocument() 55 + const nav = screen.getByRole('navigation', { name: /breadcrumb/i }) 56 + expect(nav).toHaveTextContent('Home') 57 57 }) 58 58 59 59 it('renders profile sections', async () => {
+57 -13
src/components/breadcrumbs.test.tsx
··· 7 7 const items = [ 8 8 { label: 'Home', href: '/' }, 9 9 { label: 'Development', href: '/c/development' }, 10 - { label: 'Frontend', href: '/c/development/frontend' }, 10 + { label: 'Current Page' }, 11 11 ] 12 12 13 - it('renders all breadcrumb items', () => { 13 + it('renders all breadcrumb items in desktop layout', () => { 14 14 render(<Breadcrumbs items={items} />) 15 - expect(screen.getByText('Home')).toBeInTheDocument() 16 - expect(screen.getByText('Development')).toBeInTheDocument() 17 - expect(screen.getByText('Frontend')).toBeInTheDocument() 15 + const desktopList = screen.getByRole('list') 16 + expect(desktopList).toHaveTextContent('Home') 17 + expect(desktopList).toHaveTextContent('Development') 18 + expect(desktopList).toHaveTextContent('Current Page') 18 19 }) 19 20 20 - it('renders links for non-current items', () => { 21 + it('renders links for non-last items in desktop layout', () => { 21 22 render(<Breadcrumbs items={items} />) 22 - const homeLink = screen.getByRole('link', { name: 'Home' }) 23 - expect(homeLink).toHaveAttribute('href', '/') 23 + const homeLinks = screen.getAllByRole('link', { name: 'Home' }) 24 + expect(homeLinks.length).toBeGreaterThanOrEqual(1) 25 + expect(homeLinks[0]).toHaveAttribute('href', '/') 24 26 }) 25 27 26 - it('marks last item as current page', () => { 28 + it('renders mobile back-link with last navigable item', () => { 27 29 render(<Breadcrumbs items={items} />) 28 - const current = screen.getByText('Frontend') 29 - expect(current).toHaveAttribute('aria-current', 'page') 30 + const devLinks = screen.getAllByRole('link', { name: /Development/i }) 31 + const mobileLink = devLinks.find((el) => el.classList.contains('md:hidden')) 32 + expect(mobileLink).toBeDefined() 33 + expect(mobileLink).toHaveAttribute('href', '/c/development') 34 + }) 35 + 36 + it('renders CaretLeft icon with aria-hidden in mobile back-link', () => { 37 + const { container } = render(<Breadcrumbs items={items} />) 38 + const svgs = container.querySelectorAll('svg') 39 + expect(svgs.length).toBeGreaterThan(0) 40 + const caretIcon = svgs[0] 41 + expect(caretIcon).toHaveAttribute('aria-hidden', 'true') 30 42 }) 31 43 32 44 it('has accessible navigation landmark', () => { ··· 34 46 expect(screen.getByRole('navigation', { name: /breadcrumb/i })).toBeInTheDocument() 35 47 }) 36 48 37 - it('renders separator between items', () => { 49 + it('renders separator between items in desktop layout', () => { 38 50 render(<Breadcrumbs items={items} />) 39 51 const separators = screen.getAllByText('/') 40 52 expect(separators).toHaveLength(2) 41 53 }) 42 54 43 - it('includes JSON-LD structured data', () => { 55 + it('includes JSON-LD structured data from items', () => { 44 56 const { container } = render(<Breadcrumbs items={items} />) 45 57 const script = container.querySelector('script[type="application/ld+json"]') 46 58 expect(script).toBeInTheDocument() 47 59 const jsonLd = JSON.parse(script!.textContent!) 48 60 expect(jsonLd['@type']).toBe('BreadcrumbList') 61 + // Only items with href are included in JSON-LD 62 + expect(jsonLd.itemListElement).toHaveLength(2) 63 + expect(jsonLd.itemListElement[0].name).toBe('Home') 64 + expect(jsonLd.itemListElement[1].name).toBe('Development') 65 + }) 66 + 67 + it('uses jsonLdItems for JSON-LD when provided', () => { 68 + const jsonLdItems = [ 69 + { label: 'Home', href: '/' }, 70 + { label: 'Development', href: '/c/development' }, 71 + { label: 'My Topic Title', href: '/t/my-topic/abc123' }, 72 + ] 73 + const visualItems = [ 74 + { label: 'Home', href: '/' }, 75 + { label: 'Development', href: '/c/development' }, 76 + ] 77 + const { container } = render(<Breadcrumbs items={visualItems} jsonLdItems={jsonLdItems} />) 78 + const script = container.querySelector('script[type="application/ld+json"]') 79 + const jsonLd = JSON.parse(script!.textContent!) 49 80 expect(jsonLd.itemListElement).toHaveLength(3) 81 + expect(jsonLd.itemListElement[2].name).toBe('My Topic Title') 82 + }) 83 + 84 + it('desktop layout is hidden on mobile via hidden md:flex', () => { 85 + render(<Breadcrumbs items={items} />) 86 + const desktopList = screen.getByRole('list') 87 + expect(desktopList).toHaveClass('hidden') 88 + expect(desktopList).toHaveClass('md:flex') 89 + }) 90 + 91 + it('returns null for empty items', () => { 92 + const { container } = render(<Breadcrumbs items={[]} />) 93 + expect(container.querySelector('nav')).not.toBeInTheDocument() 50 94 }) 51 95 52 96 it('passes axe accessibility check', async () => {
+27 -10
src/components/breadcrumbs.tsx
··· 1 1 /** 2 2 * Breadcrumbs component with JSON-LD structured data. 3 - * WCAG 2.2 AA: nav landmark, aria-current, semantic list. 3 + * WCAG 2.2 AA: nav landmark, semantic list, 44px mobile touch target. 4 + * Mobile: collapses to single parent back-link. 5 + * Desktop: full breadcrumb trail. 4 6 * @see https://schema.org/BreadcrumbList 5 7 */ 6 8 7 9 import Link from 'next/link' 10 + import { CaretLeft } from '@phosphor-icons/react/dist/ssr' 8 11 9 12 export interface BreadcrumbItem { 10 13 label: string ··· 13 16 14 17 interface BreadcrumbsProps { 15 18 items: BreadcrumbItem[] 19 + /** When provided, JSON-LD uses these instead of `items`. Lets pages keep full path in structured data while showing fewer visual breadcrumbs. */ 20 + jsonLdItems?: BreadcrumbItem[] 16 21 } 17 22 18 - export function Breadcrumbs({ items }: BreadcrumbsProps) { 23 + export function Breadcrumbs({ items, jsonLdItems }: BreadcrumbsProps) { 19 24 if (items.length === 0) return null 20 25 26 + const jsonLdSource = jsonLdItems ?? items 21 27 const jsonLd = { 22 28 '@context': 'https://schema.org', 23 29 '@type': 'BreadcrumbList', 24 - itemListElement: items 30 + itemListElement: jsonLdSource 25 31 .filter((item) => item.href) 26 32 .map((item, index) => ({ 27 33 '@type': 'ListItem', ··· 30 36 item: `https://barazo.forum${item.href}`, 31 37 })), 32 38 } 39 + 40 + // Last item with an href = parent link for mobile back-link 41 + const parentItem = [...items].reverse().find((item) => item.href) 33 42 34 43 return ( 35 44 <nav aria-label="Breadcrumb" className="mb-4"> ··· 37 46 type="application/ld+json" 38 47 dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} 39 48 /> 40 - <ol className="flex items-center gap-1 text-sm text-muted-foreground"> 49 + 50 + {/* Mobile: single parent back-link */} 51 + {parentItem && ( 52 + <Link 53 + href={parentItem.href!} 54 + className="flex min-h-[44px] items-center gap-1 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-sm md:hidden" 55 + > 56 + <CaretLeft size={14} aria-hidden="true" /> 57 + {parentItem.label} 58 + </Link> 59 + )} 60 + 61 + {/* Desktop: full breadcrumb trail */} 62 + <ol className="hidden items-center gap-1 text-sm text-muted-foreground md:flex"> 41 63 {items.map((item, index) => { 42 64 const isLast = index === items.length - 1 43 65 return ( ··· 48 70 </span> 49 71 )} 50 72 {isLast || !item.href ? ( 51 - <span 52 - {...(isLast ? { 'aria-current': 'page' as const } : {})} 53 - className={isLast ? 'font-medium text-foreground' : ''} 54 - > 55 - {item.label} 56 - </span> 73 + <span className={isLast ? 'font-medium text-foreground' : ''}>{item.label}</span> 57 74 ) : ( 58 75 <Link 59 76 href={item.href}