Skip to content

Commit b7695da

Browse files
authored
Add breadcrumb navigation to page titles (#26)
1 parent ebe61e3 commit b7695da

File tree

5 files changed

+215
-6
lines changed

5 files changed

+215
-6
lines changed

cypress/e2e/features/code-tabs.cy.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,7 @@ describe('Code Tabs', () => {
3737
.within(() => {
3838
cy.get('[role="tab"]').then(($tabs) => {
3939
if ($tabs.length >= 2) {
40-
// Get text of second tab
41-
const tabText = $tabs[1].textContent;
42-
// Click it
40+
// Click second tab
4341
cy.wrap($tabs[1]).click();
4442
}
4543
});

src/components/PageTitle.astro

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
---
22
import StarlightPageTitle from '@astrojs/starlight/components/PageTitle.astro';
33
import CopyPageButton from './CopyPageButton.astro';
4+
import { ContentBreadcrumbs } from './react/Breadcrumbs';
5+
6+
const currentPath = Astro.url.pathname;
7+
const sidebar = Astro.locals.starlightRoute?.sidebar ?? [];
48
---
59

6-
<div class="copy-page-row">
10+
<div class="page-nav-container">
11+
<ContentBreadcrumbs client:load currentPath={currentPath} sidebar={sidebar} />
712
<CopyPageButton />
813
</div>
914
<StarlightPageTitle />
1015

1116
<style>
12-
.copy-page-row {
17+
.page-nav-container {
1318
display: flex;
14-
justify-content: flex-end;
19+
justify-content: space-between;
20+
align-items: center;
1521
margin-bottom: 0.5rem;
1622
}
1723
</style>
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use client';
2+
3+
import { Fragment } from 'react';
4+
import {
5+
Breadcrumb,
6+
BreadcrumbItem,
7+
BreadcrumbLink,
8+
BreadcrumbList,
9+
BreadcrumbPage,
10+
BreadcrumbSeparator,
11+
} from '@/components/ui/breadcrumb';
12+
13+
type BreadcrumbData = { title: string; href: string };
14+
15+
type SidebarEntry =
16+
| { type: 'link'; label: string; href: string }
17+
| { type: 'group'; label: string; entries: SidebarEntry[] };
18+
19+
// Normalize paths to avoid mismatches due to trailing slashes
20+
function normalizePath(path: string) {
21+
return path.endsWith('/') ? path : `${path}/`;
22+
}
23+
24+
export function findBreadcrumbTrail(
25+
sidebarEntry: SidebarEntry[],
26+
targetPath: string,
27+
includeCurrentPage: boolean,
28+
trail: BreadcrumbData[] = [],
29+
): BreadcrumbData[] | null {
30+
const normalizedTarget = normalizePath(targetPath);
31+
32+
for (const entry of sidebarEntry) {
33+
if (entry.type === 'link') {
34+
const normalizedHref = normalizePath(entry.href);
35+
if (normalizedHref === normalizedTarget) {
36+
const fullTrail = [...trail, { title: entry.label, href: entry.href }];
37+
if (includeCurrentPage || fullTrail.length === 1) {
38+
return fullTrail;
39+
} else {
40+
return fullTrail.slice(0, -1);
41+
}
42+
}
43+
} else if (entry.type === 'group') {
44+
const groupBreadcrumb: BreadcrumbData = { title: entry.label, href: '' };
45+
const result = findBreadcrumbTrail(
46+
entry.entries,
47+
targetPath,
48+
includeCurrentPage,
49+
[...trail, groupBreadcrumb],
50+
);
51+
if (result) return result;
52+
}
53+
}
54+
55+
return null;
56+
}
57+
58+
export function ContentBreadcrumbs({
59+
currentPath,
60+
sidebar,
61+
}: {
62+
currentPath: string;
63+
sidebar: SidebarEntry[];
64+
}) {
65+
const breadcrumbs = findBreadcrumbTrail(sidebar, currentPath, true);
66+
67+
if (!breadcrumbs || breadcrumbs.length === 0) {
68+
return null;
69+
}
70+
71+
return (
72+
<Breadcrumb>
73+
<BreadcrumbList>
74+
{breadcrumbs.map((crumb, index) => {
75+
const isLast = index === breadcrumbs.length - 1;
76+
77+
return (
78+
<Fragment key={crumb.title}>
79+
{index > 0 && <BreadcrumbSeparator />}
80+
<BreadcrumbItem>
81+
{isLast ? (
82+
<BreadcrumbPage>{crumb.title}</BreadcrumbPage>
83+
) : crumb.href ? (
84+
<BreadcrumbLink href={crumb.href}>
85+
{crumb.title}
86+
</BreadcrumbLink>
87+
) : (
88+
<span className="text-muted-foreground">{crumb.title}</span>
89+
)}
90+
</BreadcrumbItem>
91+
</Fragment>
92+
);
93+
})}
94+
</BreadcrumbList>
95+
</Breadcrumb>
96+
);
97+
}

src/components/react/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
export { APIBody, APIEndpoint } from './APIEndpoint';
1010
export { BillingCalculator } from './BillingCalculator';
1111
export { BillingFAQ } from './BillingFAQ';
12+
export { ContentBreadcrumbs } from './Breadcrumbs';
1213
export { Callout } from './Callout';
1314
export { CodeTabs, Snippet } from './CodeTabs';
1415
export { CopyPageButton } from './CopyPageButton';

src/components/ui/breadcrumb.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { Slot } from '@radix-ui/react-slot';
2+
import { ChevronRight, MoreHorizontal } from 'lucide-react';
3+
import type * as React from 'react';
4+
5+
import { cn } from '@/lib/utils';
6+
7+
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
8+
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
9+
}
10+
11+
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
12+
return (
13+
<ol
14+
data-slot="breadcrumb-list"
15+
className={cn(
16+
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
17+
className,
18+
)}
19+
{...props}
20+
/>
21+
);
22+
}
23+
24+
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
25+
return (
26+
<li
27+
data-slot="breadcrumb-item"
28+
className={cn('inline-flex items-center gap-1.5', className)}
29+
{...props}
30+
/>
31+
);
32+
}
33+
34+
function BreadcrumbLink({
35+
asChild,
36+
className,
37+
...props
38+
}: React.ComponentProps<'a'> & {
39+
asChild?: boolean;
40+
}) {
41+
const Comp = asChild ? Slot : 'a';
42+
43+
return (
44+
<Comp
45+
data-slot="breadcrumb-link"
46+
className={cn('hover:text-foreground transition-colors', className)}
47+
{...props}
48+
/>
49+
);
50+
}
51+
52+
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
53+
return (
54+
<span
55+
data-slot="breadcrumb-page"
56+
aria-current="page"
57+
className={cn('text-foreground font-normal', className)}
58+
{...props}
59+
/>
60+
);
61+
}
62+
63+
function BreadcrumbSeparator({
64+
children,
65+
className,
66+
...props
67+
}: React.ComponentProps<'li'>) {
68+
return (
69+
<li
70+
data-slot="breadcrumb-separator"
71+
role="presentation"
72+
aria-hidden="true"
73+
className={cn('[&>svg]:size-3.5', className)}
74+
{...props}
75+
>
76+
{children ?? <ChevronRight />}
77+
</li>
78+
);
79+
}
80+
81+
function BreadcrumbEllipsis({
82+
className,
83+
...props
84+
}: React.ComponentProps<'span'>) {
85+
return (
86+
<span
87+
data-slot="breadcrumb-ellipsis"
88+
role="presentation"
89+
aria-hidden="true"
90+
className={cn('flex size-9 items-center justify-center', className)}
91+
{...props}
92+
>
93+
<MoreHorizontal className="size-4" />
94+
<span className="sr-only">More</span>
95+
</span>
96+
);
97+
}
98+
99+
export {
100+
Breadcrumb,
101+
BreadcrumbList,
102+
BreadcrumbItem,
103+
BreadcrumbLink,
104+
BreadcrumbPage,
105+
BreadcrumbSeparator,
106+
BreadcrumbEllipsis,
107+
};

0 commit comments

Comments
 (0)