Skip to content

Commit ce5b943

Browse files
committed
feat(#3589): add auto-generated breadcrumbs across all page types
Add Breadcrumbs component that derives navigation trail from URL path. Integrated into DocumentationPageLayout, TokensLayout, ExamplesPageLayout, and component page templates (inside each page's centering container to handle different max-widths). Replaces category badge on component pages and "Back to all examples" link on example pages. Component pages show their category in the breadcrumb trail with a link to the All Components page filtered by that category. ComponentsGrid now reads ?category= URL params to support this. Keyboard-only focus styles, visually-hidden current page for screen readers (aria-current="page").
1 parent de61045 commit ce5b943

8 files changed

Lines changed: 189 additions & 13 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
---
2+
/**
3+
* Breadcrumbs.astro
4+
*
5+
* Auto-generates ancestor breadcrumb trail from the current URL path.
6+
* Always shows at least the section name (e.g. "Components", "Get Started").
7+
* Use parentGroup for logical parents not reflected in the URL (e.g. component category).
8+
*/
9+
10+
interface Props {
11+
/** Optional logical parent not reflected in the URL (e.g. component category) */
12+
parentGroup?: { label: string; url?: string };
13+
}
14+
15+
const { parentGroup } = Astro.props;
16+
const pathname = Astro.url.pathname.replace(/\/$/, '');
17+
const segments = pathname.split('/').filter(Boolean);
18+
19+
// Show on any page within a section
20+
const showBreadcrumbs = segments.length >= 1;
21+
22+
// Known labels for path segments
23+
const SEGMENT_LABELS: Record<string, string> = {
24+
"get-started": "Get Started",
25+
"foundations": "Foundations",
26+
"components": "Components",
27+
"tokens": "Tokens",
28+
"examples": "Examples",
29+
"designers": "Designers",
30+
"developers": "Developers",
31+
};
32+
33+
function segmentToLabel(segment: string): string {
34+
if (SEGMENT_LABELS[segment]) return SEGMENT_LABELS[segment];
35+
// Fallback: kebab-case to sentence case
36+
return segment.split('-').map((w, i) =>
37+
i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w
38+
).join(' ');
39+
}
40+
41+
// Build ancestor crumbs: all segments except the last, but always include the section
42+
const crumbs: Array<{ label: string; url: string }> = [];
43+
let path = '';
44+
const crumbCount = Math.max(1, segments.length - 1);
45+
for (let i = 0; i < crumbCount; i++) {
46+
path += '/' + segments[i];
47+
crumbs.push({ label: segmentToLabel(segments[i]), url: path });
48+
}
49+
---
50+
51+
{showBreadcrumbs && (
52+
<nav class="breadcrumbs" aria-label="Breadcrumb">
53+
<ol>
54+
<li>
55+
<a href="/">Design System</a>
56+
<span class="separator" aria-hidden="true">/</span>
57+
</li>
58+
{crumbs.map((crumb, i) => (
59+
<li>
60+
{/* TODO: Swap to <goa-link> once underline control lands (#3504) */}
61+
<a href={crumb.url}>{crumb.label}</a>
62+
<span class="separator" aria-hidden="true">/</span>
63+
</li>
64+
))}
65+
{parentGroup && (
66+
<li>
67+
{parentGroup.url ? (
68+
<a href={parentGroup.url}>{parentGroup.label}</a>
69+
) : (
70+
<span>{parentGroup.label}</span>
71+
)}
72+
<span class="separator" aria-hidden="true">/</span>
73+
</li>
74+
)}
75+
<li class="sr-only" aria-current="page">
76+
{segmentToLabel(segments[segments.length - 1])}
77+
</li>
78+
</ol>
79+
</nav>
80+
)}
81+
82+
<style>
83+
.breadcrumbs {
84+
margin-bottom: var(--goa-space-l);
85+
}
86+
87+
.breadcrumbs ol {
88+
display: flex;
89+
flex-wrap: wrap;
90+
align-items: center;
91+
gap: var(--goa-space-2xs);
92+
list-style: none;
93+
margin: 0;
94+
padding: 0;
95+
}
96+
97+
.breadcrumbs li {
98+
display: flex;
99+
align-items: center;
100+
gap: var(--goa-space-2xs);
101+
font: var(--goa-typography-body-xs);
102+
color: var(--goa-color-greyscale-400);
103+
}
104+
105+
.breadcrumbs a {
106+
color: var(--goa-color-greyscale-500);
107+
text-decoration: none;
108+
font: var(--goa-typography-body-xs);
109+
}
110+
111+
.breadcrumbs a:hover {
112+
color: var(--goa-color-interactive-default);
113+
text-decoration: underline;
114+
}
115+
116+
.breadcrumbs a:focus {
117+
outline: none;
118+
}
119+
120+
.breadcrumbs a:focus-visible {
121+
outline: 2px solid var(--goa-color-interactive-default);
122+
outline-offset: 2px;
123+
border-radius: var(--goa-border-radius-xs);
124+
}
125+
126+
.separator {
127+
color: var(--goa-color-greyscale-300);
128+
font: var(--goa-typography-body-xs);
129+
}
130+
131+
.sr-only {
132+
position: absolute;
133+
width: 1px;
134+
height: 1px;
135+
padding: 0;
136+
margin: -1px;
137+
overflow: hidden;
138+
clip: rect(0, 0, 0, 0);
139+
white-space: nowrap;
140+
border: 0;
141+
}
142+
</style>

docs/src/components/ComponentsGrid.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,22 @@ export function ComponentsGrid({ components }: ComponentsGridProps) {
178178
status: string[];
179179
}>({ category: [], status: [] });
180180

181+
// Read URL params on mount for initial filters (e.g. ?category=inputs-and-actions)
182+
const [urlFiltersApplied, setUrlFiltersApplied] = useState(false);
183+
useEffect(() => {
184+
if (urlFiltersApplied) return;
185+
const params = new URLSearchParams(window.location.search);
186+
const fromUrl = {
187+
category: params.get("category")?.split(",").filter(Boolean) ?? [],
188+
status: params.get("status")?.split(",").filter(Boolean) ?? [],
189+
};
190+
if (fromUrl.category.length || fromUrl.status.length) {
191+
setPendingFilters(fromUrl);
192+
setAppliedFilters(fromUrl);
193+
}
194+
setUrlFiltersApplied(true);
195+
}, [urlFiltersApplied]);
196+
181197
// Hooks
182198
const { sortConfig, setSortConfig, sortByKey, clearSort, handleTableSort } =
183199
useTwoLevelSort();

docs/src/layouts/DocumentationPageLayout.astro

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
*/
1818
1919
import BaseLayout from './BaseLayout.astro';
20+
import Breadcrumbs from '../components/Breadcrumbs.astro';
2021
import { SiteNav } from '../components/SiteNav';
2122
import { MobileHeader } from '../components/MobileHeader';
2223
import { TableOfContents } from '../components/TableOfContents';
@@ -63,6 +64,9 @@ const navCategories = await getNavCategories();
6364
</div>
6465

6566
<div class="content-card">
67+
<div class="breadcrumb-row">
68+
<Breadcrumbs />
69+
</div>
6670
<div class:list={["content-grid", { "with-toc": showToc }]}>
6771
<!-- Documentation Content -->
6872
<article class="prose-content">
@@ -133,6 +137,12 @@ const navCategories = await getNavCategories();
133137
grid-template-columns: 1fr 200px;
134138
}
135139

140+
/* Breadcrumb: match content-grid centering */
141+
.breadcrumb-row {
142+
max-width: 960px;
143+
margin: 0 auto var(--goa-space-xs);
144+
}
145+
136146
/* Prose typography */
137147
.prose-content {
138148
max-width: 70ch;

docs/src/layouts/TokensLayout.astro

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
*/
1313
1414
import BaseLayout from './BaseLayout.astro';
15+
import Breadcrumbs from '../components/Breadcrumbs.astro';
1516
import { SiteNav } from '../components/SiteNav';
1617
import { MobileHeader } from '../components/MobileHeader';
1718
import { getNavCategories } from '../lib/nav-categories';
@@ -47,6 +48,9 @@ const navCategories = await getNavCategories();
4748

4849
<div class="content-with-drawer">
4950
<div class="content-card">
51+
<div class="breadcrumb-row">
52+
<Breadcrumbs />
53+
</div>
5054
<slot />
5155
</div>
5256
</div>
@@ -112,6 +116,11 @@ const navCategories = await getNavCategories();
112116
min-height: calc(100vh - var(--goa-space-l) * 2);
113117
}
114118

119+
.breadcrumb-row {
120+
max-width: 1408px;
121+
margin: 0 auto var(--goa-space-xs);
122+
}
123+
115124
/* Tablet: modal drawer mode (push drawer switches at 1023px) */
116125
@media (max-width: 1023px) {
117126
.content-card {

docs/src/pages/components/[slug].astro

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
import { getCollection, render } from 'astro:content';
33
import ComponentPageLayout from '../../layouts/ComponentPageLayout.astro';
4+
import Breadcrumbs from '../../components/Breadcrumbs.astro';
45
import PropsTable from '../../components/PropsTable.astro';
56
import GuidanceGrid from '../../components/GuidanceGrid.astro';
67
import ExampleDisplay from '../../components/ExampleDisplay.astro';
@@ -26,6 +27,8 @@ export async function getStaticPaths() {
2627
2728
const { component } = Astro.props;
2829
const { slug } = Astro.params;
30+
const categoryLabel = component.data.category
31+
.split('-').map((w: string, i: number) => i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w).join(' ');
2932
3033
// 1. Render the component MDX content
3134
const { Content } = await render(component);
@@ -60,11 +63,11 @@ const v1DocsUrl = `https://v1.design.alberta.ca/components/${slug}`;
6063
category={component.data.category}
6164
>
6265
<div class="component-page">
66+
<Breadcrumbs
67+
parentGroup={{ label: categoryLabel, url: `/components?category=${component.data.category}` }}
68+
/>
6369
<!-- Component Header -->
6470
<header class="component-header">
65-
<!-- Category Badge -->
66-
<goa-badge version="2" type="default" emphasis="subtle" icon="false" content={component.data.category.replace(/-/g, ' ')}></goa-badge>
67-
6871
<!-- Title -->
6972
<h1 class="component-title">{component.data.name}</h1>
7073

docs/src/pages/components/index.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212
import { getCollection } from 'astro:content';
1313
import ComponentPageLayout from '../../layouts/ComponentPageLayout.astro';
14+
import Breadcrumbs from '../../components/Breadcrumbs.astro';
1415
import { ComponentsGrid } from '../../components/ComponentsGrid';
1516
1617
// Get all visible components (filter out hidden)
@@ -34,6 +35,7 @@ const components = sortedComponents.map(component => ({
3435
description="Browse all GoA Design System components"
3536
>
3637
<div class="components-page">
38+
<Breadcrumbs />
3739
<!-- Header -->
3840
<header class="page-header">
3941
<h1>All Components</h1>

docs/src/pages/examples/[slug].astro

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111
import { getCollection, render } from 'astro:content';
1212
import ExamplesPageLayout from '../../layouts/ExamplesPageLayout.astro';
13+
import Breadcrumbs from '../../components/Breadcrumbs.astro';
1314
import ExampleDisplay from '../../components/ExampleDisplay.astro';
1415
import { TableOfContents } from '../../components/TableOfContents';
1516
import { getExampleCode } from '../../lib/example-code';
@@ -79,14 +80,9 @@ const v1DocsUrl = `https://v1.design.alberta.ca/examples/${slug}`;
7980
currentSlug={slug}
8081
>
8182
<article class="example-page">
83+
<Breadcrumbs />
8284
<!-- Header -->
8385
<header class="example-header">
84-
<!-- Back button -->
85-
<goa-link version="2" leadingicon="arrow-back" size="small">
86-
<a href="/examples">Back to all examples</a>
87-
</goa-link>
88-
89-
<!-- Badges -->
9086
<div class="badges">
9187
{data.categories.map((cat: string) => (
9288
<goa-badge version="2" type="information" emphasis="subtle" icon="false" content={formatCategory(cat)}></goa-badge>
@@ -166,10 +162,6 @@ const v1DocsUrl = `https://v1.design.alberta.ca/examples/${slug}`;
166162
margin-bottom: var(--goa-space-l, 1.5rem);
167163
}
168164

169-
.example-header goa-link {
170-
margin-bottom: var(--goa-space-m, 1rem);
171-
}
172-
173165
/* Badges */
174166
.badges {
175167
display: flex;

docs/src/pages/examples/index.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212
import { getCollection } from 'astro:content';
1313
import ExamplesPageLayout from '../../layouts/ExamplesPageLayout.astro';
14+
import Breadcrumbs from '../../components/Breadcrumbs.astro';
1415
import { ExamplesGrid } from '../../components/ExamplesGrid';
1516
1617
// Get all examples
@@ -35,6 +36,7 @@ const examples = sortedExamples.map(example => ({
3536
description="Code examples showing GoA Design System components in context"
3637
>
3738
<div class="examples-page">
39+
<Breadcrumbs />
3840
<!-- Header -->
3941
<header class="page-header">
4042
<h1>Examples</h1>

0 commit comments

Comments
 (0)