Skip to content

Commit 18caef0

Browse files
Merge pull request #9 from refactorfirst/add-breadcrumbs
#6 Add Breadcrumb Trail
2 parents c6c975c + db51811 commit 18caef0

14 files changed

Lines changed: 834 additions & 4 deletions

‎.github/FUNDING.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
github: refactorfirst

‎AGENTS.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket).
1010

1111
**Key Features:**
1212
- Search over curated repository listing (`repositories.txt`)
13+
- Breadcrumb trail under the menu (`components/breadcrumbs.jsx` +
14+
`lib/breadcrumbs.js`): mirrors `/user/repo/branch` with linked ancestors;
15+
the repo crumb never self-links, and ReportView announces the resolved
16+
branch (`rf:branch-resolved` event) so the label matches the loaded report
17+
(`main` 404 → `master` fallback)
1318
- Reports rendered with Mustache.js (bundled template is authoritative)
1419
- Enhanced report tables: sticky headers, pagination (>20 rows), sortable
1520
columns, in-table search, CSV export, click/keyboard cell copy with toasts
@@ -61,13 +66,15 @@ app/ # Next.js App Router (static export)
6166
globals.css # css/main.css + components.css
6267
components/ # client components: report-view, repo-list,
6368
# repo-submission-form, search-combobox,
64-
# hero-search, menu-search, menu-toggle,
65-
# workflow-sample, platform-config, sentry-provider,
69+
# hero-search, menu-search, menu-toggle, breadcrumbs
70+
# (route trail under the menu), workflow-sample,
71+
# platform-config, sentry-provider,
6672
# toast-notification (copy feedback live region)
6773
lib/ # shared logic (client + RSC): routes, fetcher,
6874
# renderer, search, utils, host, rate-limiter,
6975
# cache-manager, error-handler, repo-submission,
7076
# report-view, static-params, widget-loader,
77+
# breadcrumbs,
7178
# table-operations (filter/sort/paginate/CSV/copy +
7279
# TABLE_CONFIG + REPORT_TABLES descriptors),
7380
# table-enhancer (binds toolbar/sort/pagination/
@@ -164,7 +171,7 @@ coverage there when introducing new markup patterns.
164171

165172
## Current Test Count
166173

167-
~485 unit/integration + 175 E2E (171 across three browsers + 4 basePath leg).
174+
~543 unit/integration + ~202 E2E (~198 across three browsers + 4 basePath leg).
168175

169176
WCAG 2.2 AA / HTML5 guards live in tests/unit/html5-attributes.test.js,
170177
tests/unit/report-template-wcag.test.js, tests/unit/css-a11y.test.js and

‎app/globals.css‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,55 @@ pre {
224224
color: #fff;
225225
}
226226

227+
/* ---- Breadcrumbs (user/report trail under the top menu) ---- */
228+
.breadcrumbs {
229+
/* Same column as the top menu: #top-menu is capped by mvp.css at
230+
--width-content, so reuse the variable — the two can never drift apart.
231+
border-box (not mvp's default content-box) so the horizontal padding
232+
insets crumbs exactly like .menu-bar's padding insets the brand; with
233+
content-box, symmetric padding on a centered block never moves the
234+
content edge. */
235+
max-width: var(--width-content);
236+
box-sizing: border-box;
237+
/* mvp.css gives bare nav a 7rem bottom margin, bold text and a spaced flex
238+
row — reset all three so the trail hugs the menu like a sub-bar. */
239+
margin: 0 auto;
240+
font-weight: normal;
241+
display: block;
242+
padding: 0 0.8rem;
243+
font-size: 0.85rem;
244+
}
245+
246+
.breadcrumbs ol {
247+
display: flex;
248+
flex-wrap: wrap;
249+
align-items: center;
250+
gap: 0 0.35rem;
251+
list-style: none;
252+
margin: 0;
253+
padding: 0.1rem 0;
254+
}
255+
256+
/* Path-style separator, pure CSS so it stays decorative for screen readers
257+
(the ordered list already conveys the hierarchy). */
258+
.breadcrumbs li + li::before {
259+
content: "/";
260+
margin-right: 0.35rem;
261+
color: var(--muted-color);
262+
}
263+
264+
.breadcrumbs a,
265+
.breadcrumbs [aria-current] {
266+
display: inline-block;
267+
/* No left padding: the first crumb's glyph edge must sit at the menu
268+
column left edge (the flex-gap + separator space the rest). */
269+
padding: 0.2rem 0;
270+
}
271+
272+
.breadcrumbs [aria-current] {
273+
color: var(--muted-color);
274+
}
275+
227276
/* ---- Search results ---- */
228277
.search-results {
229278
position: absolute;

‎app/layout.jsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { Suspense } from 'react';
12
import SiteHeader from '../components/site-header';
23
import SiteFooter from '../components/site-footer';
4+
import Breadcrumbs from '../components/breadcrumbs';
35
import SentryProvider from '../components/sentry-provider';
46
import { loadListedRepositories } from '../lib/repositories.js';
57
import './globals.css';
@@ -19,6 +21,13 @@ export const metadata = {
1921
icons: { icon: '/assets/logo.png' },
2022
};
2123

24+
/**
25+
* Renders the site shell with navigation, breadcrumbs, and page content.
26+
*
27+
* @param {object} props - Layout props.
28+
* @param {import('react').ReactNode} props.children - Content of the active page.
29+
* @returns {import('react').ReactElement} The root HTML document.
30+
*/
2231
export default function RootLayout({ children }) {
2332
const repositories = loadListedRepositories();
2433
return (
@@ -35,6 +44,12 @@ export default function RootLayout({ children }) {
3544
</head>
3645
<body>
3746
<SiteHeader repositories={repositories} />
47+
{/* Breadcrumb trail under the menu (report/user routes only). Suspense
48+
is required because useSearchParams deopts otherwise (static
49+
export; the fallback keeps prerendered HTML crumb-free). */}
50+
<Suspense fallback={null}>
51+
<Breadcrumbs />
52+
</Suspense>
3853
<main id="app" tabIndex={-1}>{children}</main>
3954
<SiteFooter />
4055
<SentryProvider />

‎components/breadcrumbs.jsx‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
'use client';
2+
3+
// Breadcrumb trail rendered under the top menu: mirrors the current
4+
// /<user>/<repo>/<branch> route so report viewers can navigate back up the
5+
// hierarchy. Ancestor crumbs are links; the last crumb is the current page
6+
// (plain text with aria-current="page"). Non-report routes render nothing.
7+
//
8+
// The report fetch announces the branch it actually loaded (a main 404 falls
9+
// back to master) via BRANCH_RESOLVED_EVENT; the current crumb is relabelled
10+
// to the resolved branch so the trail identifies the displayed report.
11+
import { useEffect, useState } from 'react';
12+
import Link from 'next/link';
13+
import { usePathname, useSearchParams } from 'next/navigation';
14+
import {
15+
breadcrumbsFor,
16+
reportMatchesFor,
17+
BRANCH_RESOLVED_EVENT
18+
} from '../lib/breadcrumbs.js';
19+
20+
/**
21+
* Renders the current route's breadcrumb trail and tracks the loaded branch.
22+
*
23+
* @returns {import('react').ReactElement|null} Breadcrumb navigation, or null
24+
* when the route is neither a user listing nor a repository report.
25+
*/
26+
export default function Breadcrumbs() {
27+
const pathname = usePathname();
28+
const searchParams = useSearchParams();
29+
const search = searchParams ? searchParams.toString() : '';
30+
const routeKey = `${pathname}?${search}`;
31+
// The announced branch is keyed to the route it was announced on; a route
32+
// change resets it during render (the React-sanctioned alternative to a
33+
// setState call inside an effect).
34+
const [resolved, setResolved] = useState({ key: routeKey, branch: null });
35+
if (resolved.key !== routeKey) {
36+
setResolved({ key: routeKey, branch: null });
37+
}
38+
39+
useEffect(() => {
40+
/**
41+
* Updates the branch label for announcements matching the current report.
42+
*
43+
* @param {CustomEvent<{username: string, repository: string, branch: string}>} event
44+
* Report identity and the branch that successfully loaded.
45+
* @returns {void}
46+
*/
47+
const onBranchResolved = event => {
48+
if (!reportMatchesFor(pathname || '/', event.detail)) return;
49+
const { branch } = event.detail;
50+
if (branch) setResolved({ key: routeKey, branch });
51+
};
52+
window.addEventListener(BRANCH_RESOLVED_EVENT, onBranchResolved);
53+
return () => window.removeEventListener(BRANCH_RESOLVED_EVENT, onBranchResolved);
54+
}, [pathname, routeKey]);
55+
56+
const crumbs = breadcrumbsFor(
57+
pathname || '/',
58+
search,
59+
resolved.key === routeKey ? resolved.branch : null
60+
);
61+
if (crumbs.length === 0) return null;
62+
return (
63+
<nav className="breadcrumbs" aria-label="Breadcrumb">
64+
<ol>
65+
{/* Key by position: crumbs are strictly ordered and labels may
66+
repeat when username === repository (e.g. refactorfirst/…). */}
67+
{crumbs.map((crumb, index) => (
68+
<li key={index}>
69+
{crumb.current ? (
70+
<span aria-current="page">{crumb.label}</span>
71+
) : crumb.href ? (
72+
<Link href={crumb.href}>{crumb.label}</Link>
73+
) : (
74+
<span>{crumb.label}</span>
75+
)}
76+
</li>
77+
))}
78+
</ol>
79+
</nav>
80+
);
81+
}

‎components/report-view.jsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Script from 'next/script';
1414
import { withBasePath } from '../lib/base-path';
1515
import { detectHostingEnvironment, getPlatformBaseUrl, readMetaTag } from '../lib/host';
1616
import { fetchReport } from '../lib/fetcher';
17+
import { announceResolvedBranch } from '../lib/breadcrumbs.js';
1718
import { renderTemplate, prepareReportData } from '../lib/renderer';
1819
import {
1920
enhanceReport,
@@ -127,6 +128,9 @@ export default function ReportView({
127128
});
128129

129130
if (controller.signal.aborted) return;
131+
// Tell the breadcrumb trail which branch actually loaded (a main
132+
// 404 falls back to master) so its label matches the report shown.
133+
announceResolvedBranch(window, { username, repository, branch: resolvedBranch });
130134
setTableStates({});
131135
setPayload({ template, data, resolvedBranch });
132136
} catch (error) {

‎lib/breadcrumbs.js‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Pure breadcrumb model: maps the current route to the trail rendered under
2+
// the top menu. Each crumb is { label, href } for navigable ancestors,
3+
// { label, current: true } for the page being viewed, or a plain { label }
4+
// when the only sensible link target would be the page itself (crumbs never
5+
// self-link). Routes that are not a user listing or repository report get no
6+
// trail (empty array), so landing and static content pages stay crumb-free.
7+
import { classifyRoute, buildReportUrl, getQueryParam } from './routes.js';
8+
9+
// CustomEvent dispatched by ReportView once the report fetch resolves the
10+
// branch actually loaded (a `main` 404 falls back to `master`). Breadcrumbs
11+
// listens and relabels the current crumb so it identifies the report being
12+
// displayed instead of the default guess.
13+
export const BRANCH_RESOLVED_EVENT = 'rf:branch-resolved';
14+
15+
/**
16+
* Creates a branch-resolution event in the dispatch target's realm.
17+
* Falls back to an Event carrying detail when CustomEvent is unavailable or
18+
* incompatible with Event in the current runtime.
19+
*
20+
* @param {EventTarget} target - Window, DOM node, or standalone event target.
21+
* @param {{username: string, repository: string, branch: string}} detail
22+
* The report identity and resolved branch carried by the event.
23+
* @returns {Event & {detail: object}} An event compatible with the target.
24+
*/
25+
function branchResolvedEvent(target, detail) {
26+
const realm =
27+
(target && target.window === target && target) || // a Window
28+
target?.ownerDocument?.defaultView || // a DOM Node
29+
null;
30+
if (realm && typeof realm.CustomEvent === 'function') {
31+
return new realm.CustomEvent(BRANCH_RESOLVED_EVENT, { detail });
32+
}
33+
if (typeof CustomEvent === 'function' && new CustomEvent('') instanceof Event) {
34+
return new CustomEvent(BRANCH_RESOLVED_EVENT, { detail });
35+
}
36+
return Object.assign(new Event(BRANCH_RESOLVED_EVENT), { detail });
37+
}
38+
39+
/**
40+
* Builds a user-listing path with the username encoded as one URL segment.
41+
*
42+
* @param {string} username - Repository owner's username.
43+
* @returns {string} The user-listing path, without a deployment base path.
44+
*/
45+
function userHref(username) {
46+
return `/${encodeURIComponent(username)}`;
47+
}
48+
49+
/**
50+
* Announces the branch a report fetch actually resolved to.
51+
*
52+
* @param {EventTarget} target - Dispatch target (window in the browser).
53+
* @param {{username: string, repository: string, branch: string}} report
54+
* The report identity plus its resolved branch.
55+
*/
56+
export function announceResolvedBranch(target, { username, repository, branch }) {
57+
target.dispatchEvent(
58+
branchResolvedEvent(target, { username, repository, branch })
59+
);
60+
}
61+
62+
/**
63+
* Checks whether a branch-resolution announcement belongs to the report
64+
* route currently being viewed (stale announcements from a previous page
65+
* must not relabel it).
66+
*
67+
* @param {string} path - Current pathname.
68+
* @param {{username?: string, repository?: string}} detail - Event detail.
69+
* @returns {boolean} True when the announcement matches the viewed report.
70+
*/
71+
export function reportMatchesFor(path, detail = {}) {
72+
const route = classifyRoute(path);
73+
return route.type === 'report'
74+
&& route.username === detail.username
75+
&& route.repository === detail.repository;
76+
}
77+
78+
/**
79+
* Builds the breadcrumb trail for a path.
80+
*
81+
* @param {string} path - Current pathname (basePath already stripped by Next).
82+
* @param {string} [search] - Query string conveying ?branch= on deep links.
83+
* @param {string|null} [resolvedBranch] - Branch the report actually loaded
84+
* from (announced by ReportView after the fetch; null beforehand).
85+
* @returns {Array<{label: string, href?: string, current?: boolean}>} Crumbs
86+
* in hierarchical order; the last crumb is the current page.
87+
*/
88+
export function breadcrumbsFor(path, search = '', resolvedBranch = null) {
89+
const route = classifyRoute(path);
90+
91+
if (route.type === 'user') {
92+
return [
93+
{ label: 'Home', href: '/' },
94+
{ label: route.username, current: true }
95+
];
96+
}
97+
98+
if (route.type === 'report') {
99+
const hasBranchSegment = path.split('/').filter(Boolean).length > 2;
100+
const explicitBranch = hasBranchSegment
101+
? route.branch
102+
: getQueryParam(search, 'branch');
103+
// The resolved branch outranks any explicit/requested branch: when main
104+
// 404s and the fallback loads master, the label must say "master".
105+
const branch = resolvedBranch || explicitBranch || null;
106+
107+
const crumbs = [
108+
{ label: 'Home', href: '/' },
109+
{ label: route.username, href: userHref(route.username) }
110+
];
111+
112+
if (!branch) {
113+
// Plain /user/repo before the report announces its branch: the
114+
// repository crumb IS the current page (linking it would self-link),
115+
// and the branch is not guessed — main may resolve to master.
116+
crumbs.push({ label: route.repository, current: true });
117+
return crumbs;
118+
}
119+
120+
// With an explicit branch (path segment or ?branch=), the repository
121+
// crumb navigates back up to the default-branch report. Without one the
122+
// only target would be the current page, so the crumb stays unlinked.
123+
crumbs.push(
124+
explicitBranch
125+
? { label: route.repository, href: buildReportUrl(route.username, route.repository) }
126+
: { label: route.repository },
127+
{ label: branch, current: true }
128+
);
129+
return crumbs;
130+
}
131+
132+
return [];
133+
}

0 commit comments

Comments
 (0)