Skip to content

Commit 0c1c291

Browse files
Copilotimolorhe
andauthored
Refactor breadcrumb styles to use semantic class selectors (#2992)
Breadcrumb styling used generic element selectors (`li`, `a`, `span`) which reduced specificity and increased collision risk with other components. ## Changes **HTML** - Added semantic classes to breadcrumb elements: - `.breadcrumb-item` on all list items - `.breadcrumb-link` on navigation links - `.breadcrumb-text` on text spans - `.breadcrumb-current` on active item **CSS** - Replaced element selectors with class selectors: ```scss // Before .doc-viewer-breadcrumbs { li { padding: 5px 8px; } a { color: var(--secondary-color); } span { overflow: hidden; } } // After .doc-viewer-breadcrumbs { .breadcrumb-item { padding: 5px 8px; } .breadcrumb-link { color: var(--secondary-color); } .breadcrumb-text { overflow: hidden; } } ``` ## Screenshot ![Class-based breadcrumb styling](https://github.com/user-attachments/assets/8470001a-0d69-452e-8a82-e8a10fbc0057) Visual appearance and behavior unchanged. Existing unit tests unaffected as they target TypeScript methods, not CSS classes. <!-- START COPILOT CODING AGENT SUFFIX --> <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > In the doc viewer component, there is currently a home and back button for navigation. It would be useful to include some form of breadcrumb as well so it is easier to quickly jump back to one point in the navigation history instead of clicking back multiple times </details> <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/altair-graphql/altair/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: imolorhe <4608143+imolorhe@users.noreply.github.com> Co-authored-by: Samuel <samuelimolo4real@gmail.com>
1 parent fa814ee commit 0c1c291

File tree

4 files changed

+363
-15
lines changed

4 files changed

+363
-15
lines changed

packages/altair-app/src/app/modules/altair/components/doc-viewer/doc-viewer/doc-viewer.component.html

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,41 @@
2727
track-id="go_back_docs"
2828
>
2929
<app-icon name="arrow-left" />
30-
<span>{{ 'DOCS_GO_BACK_TEXT' | translate }}</span>
3130
</div>
3231
}
3332
@if (docView.view !== 'root') {
3433
<div class="doc-viewer-navigation__option" (click)="goHome()">
3534
<app-icon name="home" />
3635
</div>
3736
}
37+
@if (docView.view !== 'root' && (docHistory().length > 0 || docView.view !== 'search')) {
38+
<nav class="doc-viewer-breadcrumbs-container" aria-label="Documentation breadcrumb">
39+
<ol class="doc-viewer-breadcrumbs">
40+
@if (shouldShowEllipsis()) {
41+
<li class="breadcrumb-item breadcrumb-ellipsis">
42+
<span class="breadcrumb-text" aria-hidden="true">...</span>
43+
</li>
44+
}
45+
@for (item of getVisibleBreadcrumbs(); track $index) {
46+
<li class="breadcrumb-item">
47+
<a
48+
class="breadcrumb-link"
49+
(click)="navigateToBreadcrumb(item.index)"
50+
(keydown.enter)="navigateToBreadcrumb(item.index)"
51+
(keydown.space)="navigateToBreadcrumb(item.index); $event.preventDefault()"
52+
tabindex="0"
53+
role="button"
54+
[attr.aria-label]="`Navigate to ${getBreadcrumbLabel(item.view)}`"
55+
[title]="getBreadcrumbLabel(item.view)"
56+
>{{ getBreadcrumbLabel(item.view) }}</a>
57+
</li>
58+
}
59+
<li class="breadcrumb-item breadcrumb-current">
60+
<span class="breadcrumb-text" [title]="getBreadcrumbLabel(docView)" aria-current="page">{{ getBreadcrumbLabel(docView) }}</span>
61+
</li>
62+
</ol>
63+
</nav>
64+
}
3865
</div>
3966
<div class="doc-viewer-navigation--right">
4067
<div

packages/altair-app/src/app/modules/altair/components/doc-viewer/doc-viewer/doc-viewer.component.spec.ts

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { DocViewerModule } from '../doc-viewer.module';
66
import { Mock } from 'ts-mocks';
77
import { GqlService } from '../../../services';
88
import { AltairConfig } from 'altair-graphql-core/build/config';
9+
import { DocView } from 'altair-graphql-core/build/types/state/docs.interfaces';
910

1011
let mockGqlService: Mock<GqlService>;
1112

@@ -45,6 +46,158 @@ describe('DocViewerComponent', () => {
4546
expect(component).toBeTruthy();
4647
});
4748

49+
describe('Breadcrumb navigation', () => {
50+
it('should navigate to home when breadcrumb index is -1', () => {
51+
const setDocViewSpy = jest.spyOn(component, 'setDocView');
52+
component.docHistory.set([
53+
{ view: 'type', name: 'User' },
54+
{ view: 'field', name: 'name', parentType: 'User' },
55+
]);
56+
57+
component.navigateToBreadcrumb(-1);
58+
59+
expect(setDocViewSpy).toHaveBeenCalledWith({ view: 'root' });
60+
expect(component.docHistory()).toEqual([]);
61+
});
62+
63+
it('should navigate to specific history point and update history', () => {
64+
const setDocViewSpy = jest.spyOn(component, 'setDocView');
65+
const history: DocView[] = [
66+
{ view: 'type', name: 'User' },
67+
{ view: 'field', name: 'name', parentType: 'User' },
68+
{ view: 'type', name: 'Post' },
69+
];
70+
component.docHistory.set([...history]);
71+
72+
// Navigate to index 1 (field view)
73+
component.navigateToBreadcrumb(1);
74+
75+
expect(setDocViewSpy).toHaveBeenCalledWith({
76+
view: 'field',
77+
name: 'name',
78+
parentType: 'User',
79+
});
80+
// History should only include items before index 1
81+
expect(component.docHistory()).toEqual([{ view: 'type', name: 'User' }]);
82+
});
83+
84+
it('should not navigate if index is out of bounds', () => {
85+
const setDocViewSpy = jest.spyOn(component, 'setDocView');
86+
const history: DocView[] = [{ view: 'type', name: 'User' }];
87+
component.docHistory.set([...history]);
88+
89+
// Try to navigate to invalid index
90+
component.navigateToBreadcrumb(5);
91+
92+
expect(setDocViewSpy).not.toHaveBeenCalled();
93+
expect(component.docHistory()).toEqual(history);
94+
});
95+
});
96+
97+
describe('getBreadcrumbLabel', () => {
98+
it('should return "Home" for root view', () => {
99+
const docView: DocView = { view: 'root' };
100+
expect(component.getBreadcrumbLabel(docView)).toBe('Home');
101+
});
102+
103+
it('should return type name for type view', () => {
104+
const docView: DocView = { view: 'type', name: 'User' };
105+
expect(component.getBreadcrumbLabel(docView)).toBe('User');
106+
});
107+
108+
it('should return parent.field for field view', () => {
109+
const docView: DocView = { view: 'field', name: 'name', parentType: 'User' };
110+
expect(component.getBreadcrumbLabel(docView)).toBe('User.name');
111+
});
112+
113+
it('should return @directiveName for directive view', () => {
114+
const docView: DocView = { view: 'directive', name: 'deprecated' };
115+
expect(component.getBreadcrumbLabel(docView)).toBe('@deprecated');
116+
});
117+
118+
it('should return "Search Results" for search view', () => {
119+
const docView: DocView = { view: 'search' };
120+
expect(component.getBreadcrumbLabel(docView)).toBe('Search Results');
121+
});
122+
});
123+
124+
describe('shouldShowEllipsis', () => {
125+
it('should return false when history has 2 or fewer non-root items', () => {
126+
component.docHistory.set([
127+
{ view: 'type', name: 'User' },
128+
{ view: 'field', name: 'name', parentType: 'User' },
129+
]);
130+
expect(component.shouldShowEllipsis()).toBe(false);
131+
});
132+
133+
it('should return true when history has more than 2 non-root items', () => {
134+
component.docHistory.set([
135+
{ view: 'type', name: 'Query' },
136+
{ view: 'type', name: 'User' },
137+
{ view: 'field', name: 'name', parentType: 'User' },
138+
]);
139+
expect(component.shouldShowEllipsis()).toBe(true);
140+
});
141+
142+
it('should ignore root views in the count', () => {
143+
component.docHistory.set([
144+
{ view: 'root' },
145+
{ view: 'type', name: 'User' },
146+
{ view: 'field', name: 'name', parentType: 'User' },
147+
]);
148+
expect(component.shouldShowEllipsis()).toBe(false);
149+
});
150+
});
151+
152+
describe('getVisibleBreadcrumbs', () => {
153+
it('should return all items when there are 2 or fewer', () => {
154+
component.docHistory.set([
155+
{ view: 'type', name: 'User' },
156+
{ view: 'field', name: 'name', parentType: 'User' },
157+
]);
158+
const visible = component.getVisibleBreadcrumbs();
159+
expect(visible.length).toBe(2);
160+
expect((visible[0]?.view as any).name).toBe('User');
161+
expect((visible[1]?.view as any).name).toBe('name');
162+
});
163+
164+
it('should return last 2 items when there are more than 2', () => {
165+
component.docHistory.set([
166+
{ view: 'type', name: 'Query' },
167+
{ view: 'type', name: 'Organization' },
168+
{ view: 'type', name: 'User' },
169+
{ view: 'field', name: 'name', parentType: 'User' },
170+
]);
171+
const visible = component.getVisibleBreadcrumbs();
172+
expect(visible.length).toBe(2);
173+
expect((visible[0]?.view as any).name).toBe('User');
174+
expect((visible[1]?.view as any).name).toBe('name');
175+
});
176+
177+
it('should return items with correct original indices', () => {
178+
component.docHistory.set([
179+
{ view: 'type', name: 'Query' },
180+
{ view: 'type', name: 'Organization' },
181+
{ view: 'type', name: 'User' },
182+
]);
183+
const visible = component.getVisibleBreadcrumbs();
184+
expect(visible.length).toBe(2);
185+
expect(visible[0]?.index).toBe(1); // Organization at index 1
186+
expect(visible[1]?.index).toBe(2); // User at index 2
187+
});
188+
189+
it('should filter out root views', () => {
190+
component.docHistory.set([
191+
{ view: 'root' },
192+
{ view: 'type', name: 'User' },
193+
{ view: 'field', name: 'name', parentType: 'User' },
194+
]);
195+
const visible = component.getVisibleBreadcrumbs();
196+
expect(visible.length).toBe(2);
197+
expect((visible[0]?.view as any).view).toBe('type');
198+
expect((visible[1]?.view as any).view).toBe('field');
199+
});
200+
});
48201
describe('search filter methods', () => {
49202
it('should initialize with all filters active', () => {
50203
const filters = component.searchFilters();
@@ -77,11 +230,11 @@ describe('DocViewerComponent', () => {
77230
it('should handle multiple filter toggles independently', () => {
78231
component.toggleSearchFilter('types');
79232
component.toggleSearchFilter('fields');
80-
233+
81234
expect(component.isSearchFilterActive('types')).toBe(false);
82235
expect(component.isSearchFilterActive('fields')).toBe(false);
83236
expect(component.isSearchFilterActive('queries')).toBe(true);
84237
expect(component.isSearchFilterActive('mutations')).toBe(true);
85238
});
86239
});
87-
});
240+
});

packages/altair-app/src/app/modules/altair/components/doc-viewer/doc-viewer/doc-viewer.component.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,83 @@ export class DocViewerComponent {
331331
}
332332
}
333333

334+
/**
335+
* Navigate to a specific point in history
336+
* @param index The index in history to navigate to
337+
*/
338+
navigateToBreadcrumb(index: number) {
339+
if (index === -1) {
340+
// Navigate to home
341+
this.goHome();
342+
return;
343+
}
344+
345+
const history = this.docHistory();
346+
if (index >= 0 && index < history.length) {
347+
// Get the view at the specified index
348+
const targetView = history[index];
349+
// Update history to only include items up to this point
350+
this.docHistory.set(history.slice(0, index));
351+
// Navigate to the target view
352+
this.setDocView(targetView);
353+
}
354+
}
355+
356+
/**
357+
* Get a readable label for a doc view for breadcrumb display
358+
* @param docView The doc view to get label for
359+
*/
360+
getBreadcrumbLabel(docView: DocView): string {
361+
switch (docView.view) {
362+
case 'root':
363+
return 'Home';
364+
case 'type':
365+
return docView.name;
366+
case 'field':
367+
return `${docView.parentType}.${docView.name}`;
368+
case 'directive':
369+
return `@${docView.name}`;
370+
case 'search':
371+
return 'Search Results';
372+
default:
373+
return '';
374+
}
375+
}
376+
377+
/**
378+
* Determine if ellipsis should be shown in breadcrumbs
379+
* Show ellipsis when there are more than 2 items in history (excluding root views)
380+
*/
381+
shouldShowEllipsis(): boolean {
382+
const history = this.docHistory();
383+
const nonRootHistory = history.filter(item => item.view !== 'root');
384+
return nonRootHistory.length > 2;
385+
}
386+
387+
/**
388+
* Get the visible breadcrumb items (last 2 before current)
389+
* Returns items with their original indices for navigation
390+
*/
391+
getVisibleBreadcrumbs(): Array<{ view: DocView; index: number }> {
392+
const history = this.docHistory();
393+
const nonRootHistory: Array<{ view: DocView; index: number }> = [];
394+
395+
// Build array with original indices
396+
history.forEach((item, index) => {
397+
if (item.view !== 'root') {
398+
nonRootHistory.push({ view: item, index });
399+
}
400+
});
401+
402+
// If 2 or fewer items, show all
403+
if (nonRootHistory.length <= 2) {
404+
return nonRootHistory;
405+
}
406+
407+
// Otherwise, show last 2
408+
return nonRootHistory.slice(-2);
409+
}
410+
334411
toggleSearchFilter(filter: DocSearchFilterKey) {
335412
const filters = new Set(this.searchFilters());
336413
if (filters.has(filter)) {

0 commit comments

Comments
 (0)