Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f59ca88
feat(components): implement segment specific breadcrumbs
alionazherdetska Sep 10, 2026
880296b
improved the docs
alionazherdetska Sep 10, 2026
d873d86
improved the comment
alionazherdetska Sep 10, 2026
c89a0ca
autogenerated
alionazherdetska Sep 10, 2026
2382fbd
Merge branch '8072-bug-breadcrumb-links-reload-the-page-in-a-nextjs-aโ€ฆ
oliverschuerch Sep 10, 2026
e1ea310
Merge branch '8072-bug-breadcrumb-links-reload-the-page-in-a-nextjs-aโ€ฆ
alionazherdetska Sep 10, 2026
0c2d2e3
initial fix
alionazherdetska Sep 10, 2026
ddc0046
added the tests
alionazherdetska Sep 10, 2026
45235be
reverted redundant changes
alionazherdetska Sep 10, 2026
f951c8a
refactor
alionazherdetska Sep 10, 2026
cf16668
updated tests
alionazherdetska Sep 10, 2026
b7389e4
improved coomments
alionazherdetska Sep 10, 2026
7f88094
improved tests
alionazherdetska Sep 10, 2026
6aad3ce
improved components
alionazherdetska Sep 10, 2026
1e6ab80
resolved sonar
alionazherdetska Sep 10, 2026
f2e74be
resolved sonar
alionazherdetska Sep 10, 2026
3cf91b1
removed redundant comments
alionazherdetska Sep 10, 2026
f41326e
improved the comments
alionazherdetska Sep 10, 2026
346d0a6
autogenerated file
alionazherdetska Sep 10, 2026
3e7cdeb
removed redundant code
alionazherdetska Sep 10, 2026
d0a9fe9
fixed next error
alionazherdetska Sep 10, 2026
93d51fb
improved the implementation
alionazherdetska Sep 10, 2026
eced5b9
reverted redundant attributes
alionazherdetska Sep 10, 2026
1492520
reverted redundant attributes
alionazherdetska Sep 10, 2026
95b2f76
improved the chnageset
alionazherdetska Sep 10, 2026
3700120
fixed the menu styles
alionazherdetska Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funny-train-ride.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@swisspost/design-system-components': minor
---

Added a `show-home-text` property to `<post-breadcrumbs>` for segment specific breadcrumbs, displaying `text-home` as visible text instead of the home icon. The home item's own overflow menu is labelled via the new `text-expand-home` property.
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,28 @@ The `<post-breadcrumbs>` is a container for `<post-breadcrumb-item>` components,

## Examples

### Concatenated Breadcrumbs
<post-tabs size="large">
<post-tab-item name="concatenated">Concatenated</post-tab-item>
<post-tab-item name="home-text">Segment Specific Breadcrumbs</post-tab-item>
<post-tab-item name="client-side-routing">Client-Side Routing</post-tab-item>

<post-tab-panel for="concatenated" class="pt-24">

When space is constrained, the `<post-breadcrumbs>` concatenates middle items into a dropdown menu.

<Canvas of={BreadcrumbsStories.Concatenated} sourceState="shown" />

### Routing with Client-Side Frameworks
</post-tab-panel>

<post-tab-panel for="home-text" class="pt-24">

By default the root breadcrumb shows a home icon, with `text-home` used only as its accessible label. Set `show-home-text` to `true` to show `text-home` visibly instead โ€” useful when the root should link to a segment's own home page (e.g. "Private customers", "About us") rather than the generic site root.

<Canvas of={BreadcrumbsStories.CustomHomeText} sourceState="shown" />

</post-tab-panel>

<post-tab-panel for="client-side-routing" class="pt-24">

To enable client-side routing (e.g. Next.js, Angular Router), slot your own anchor instead of using the `url`/`home-url` properties: use the default slot for `<post-breadcrumb-item>`, or the `home` slot for the root item of `<post-breadcrumbs>`. Both components detect the slotted `<a>` and stop rendering their own, leaving your link's click handling untouched.

Expand All @@ -60,3 +75,6 @@ To enable client-side routing (e.g. Next.js, Angular Router), slot your own anch
</post-banner>

<Canvas of={BreadcrumbsStories.ClientSideRouting} sourceState="shown" />

</post-tab-panel>
</post-tabs>
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const meta: MetaComponent = {
textHome: 'Home',
textBreadcrumbs: 'Breadcrumbs',
textMoreItems: 'More items',
showHomeText: false,
itemCount: 3,
},
};
Expand All @@ -34,6 +35,7 @@ function render(args: Args) {
text-home=${args.textHome}
text-breadcrumbs=${args.textBreadcrumbs}
text-more-items=${args.textMoreItems}
?show-home-text=${args.showHomeText}
>
${Array.from({ length: args.itemCount }).map(
(_, i) =>
Expand All @@ -57,6 +59,36 @@ export const Concatenated: Story = {
},
};

export const CustomHomeText: Story = {
args: {
showHomeText: true,
textHome: 'Private customers',
},
parameters: {
docs: {
description: {
story:
"Set `show-home-text` to `true` to replace the home icon with the visible text set on `text-home`, enabling segment specific breadcrumbs (e.g. to link to a segment's own home page instead of the generic site root). Unlike the middle segments, home and the last (selected) segment are never simply collapsed into the menu โ€” home gets its own menu as a last resort, and the last segment wraps onto multiple lines only if that's still not enough.",
},
},
},
render: args => html`
<post-breadcrumbs
home-url=${args.homeUrl}
text-home=${args.textHome}
text-breadcrumbs=${args.textBreadcrumbs}
text-more-items=${args.textMoreItems}
?show-home-text=${args.showHomeText}
>
<post-breadcrumb-item url="/section1">Send letters</post-breadcrumb-item>
<post-breadcrumb-item url="/section2">Letters abroad</post-breadcrumb-item>
<post-breadcrumb-item url="/section3">Europe</post-breadcrumb-item>
<post-breadcrumb-item url="/section4">Neighbouring countries</post-breadcrumb-item>
<post-breadcrumb-item url="/section5">Letters to Switzerland</post-breadcrumb-item>
</post-breadcrumbs>
`,
};

export const ClientSideRouting: Story = {
args: {
itemCount: 3,
Expand Down
227 changes: 221 additions & 6 deletions packages/components/cypress/e2e/breadcrumbs.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,49 @@ const BREADCRUMB_ITEM_ID = 'b7db7391-f893-4b1e-a125-b30c6f0b028d';
const BREADCRUMBS_ID = 'b7db7391-f893-4b1e-a125-b30c6f0b028b';

describe('breadcrumbs', () => {
// Checks home collapses at a width no narrower than where the last item wraps, across a
// range of widths rather than one fragile pixel value. Requires a `@breadcrumbs` alias.
function assertHomeCollapsesNoLaterThanLastItemWraps() {
const widths = [500, 400, 320, 280, 240, 200, 180, 160, 140, 120, 100, 90, 80];
let homeCollapsedWidth: number | null = null;
let standaloneWidth: number | null = null;

widths.forEach(width => {
cy.viewport(width, 400);
cy.wait(100);

cy.get('@breadcrumbs')
.shadow()
.then($shadow => {
if (
homeCollapsedWidth === null &&
$shadow.find('nav:not(.invisible) .home-menu').length > 0
) {
homeCollapsedWidth = width;
}
});

cy.get('@breadcrumbs').then($breadcrumbs => {
const selected = $breadcrumbs
.find('post-breadcrumb-item[selected]:not([selected="false"])')
.filter((_, el) => !el.closest('.invisible'));
if (standaloneWidth === null && selected.is('[standalone]:not([standalone="false"])')) {
standaloneWidth = width;
}
});
});

cy.then(() => {
expect(homeCollapsedWidth, 'home never collapsed across the tested width range').to.not.equal(
null,
);
expect(standaloneWidth, 'last item never wrapped across the tested width range').to.not.equal(
null,
);
expect(homeCollapsedWidth).to.be.at.least(standaloneWidth);
});
}

describe('item', () => {
describe('default (internal anchor)', () => {
beforeEach(() => {
Expand Down Expand Up @@ -47,6 +90,107 @@ describe('breadcrumbs', () => {
});
});

describe('concatenation', () => {
describe('visible item cap', () => {
beforeEach(() => {
cy.getComponent('breadcrumbs', BREADCRUMBS_ID, 'default');
cy.get('post-breadcrumbs[data-hydrated]', { timeout: 30000 }).as('breadcrumbs');
});

it('should not collapse any items when everything fits comfortably', () => {
cy.get('@breadcrumbs').find('post-breadcrumb-item[variant="menuitem"]').should('not.exist');
});
});

describe('collapse ordering', () => {
beforeEach(() => {
cy.getComponent('breadcrumbs', BREADCRUMBS_ID, 'concatenated');
cy.get('post-breadcrumbs[data-hydrated]', { timeout: 30000 }).as('breadcrumbs');
});

it('should never show more than the maximum number of visible items, even with ample width', () => {
cy.viewport(1920, 800);
// The off-screen measurement clone always force-expands to variant="listitem", so it
// never matches [variant="menuitem"] and doesn't need to be filtered out here.
cy.get('@breadcrumbs')
.find('post-breadcrumb-item[variant="menuitem"]')
.should('have.length', 15 - 6); // itemCount (15, Concatenated story) minus MAX_VISIBLE_ITEMS
});

it('should collapse items starting from the earliest segment, closest to home', () => {
cy.get('@breadcrumbs')
.find('post-breadcrumb-item')
// Excludes the off-screen measurement clone, which duplicates every item.
.filter((_, el) => !el.closest('.invisible'))
.then($items => {
const variants = $items.toArray().map(item => item.getAttribute('variant'));
const firstListItemIndex = variants.indexOf('listitem');
expect(variants.slice(0, firstListItemIndex)).to.satisfy((v: string[]) =>
v.every(variant => variant === 'menuitem'),
);
});
});

it('should not mark the last item standalone while middle items are still visible', () => {
cy.get('@breadcrumbs')
.find('post-breadcrumb-item[variant="menuitem"]')
.should('have.length.greaterThan', 0);
cy.get('@breadcrumbs')
.find('post-breadcrumb-item[selected]:not([selected="false"])')
.filter((_, el) => !el.closest('.invisible'))
// `standalone` reflects as an empty-string attribute when true, not the text "true".
.should('not.match', '[standalone]:not([standalone="false"])');
});
});

// Degrade order: middle items collapse first -> home collapses into its own menu ->
// only then does the last item wrap.
describe('three-stage degrade sequence', () => {
beforeEach(() => {
cy.getComponent('breadcrumbs', BREADCRUMBS_ID, 'default');
cy.get('post-breadcrumbs[data-hydrated]', { timeout: 30000 }).as('breadcrumbs');
});

it('should collapse middle items first, before home collapses or the last item wraps', () => {
cy.viewport(280, 400);
cy.get('@breadcrumbs')
.find('post-breadcrumb-item[variant="menuitem"]')
.should('have.length.greaterThan', 0);
cy.get('@breadcrumbs').shadow().find('nav:not(.invisible) .home-menu').should('not.exist');
cy.get('@breadcrumbs')
.find('post-breadcrumb-item[selected]:not([selected="false"])')
.filter((_, el) => !el.closest('.invisible'))
.should('not.match', '[standalone]:not([standalone="false"])');
});

it('should collapse the home item at a width no narrower than where the last item starts wrapping', () => {
assertHomeCollapsesNoLaterThanLastItemWraps();
});

it('should mark the last item standalone only once home has also collapsed and there is still no room', () => {
cy.viewport(120, 400);
cy.get('@breadcrumbs').shadow().find('nav:not(.invisible) .home-menu').should('exist');
cy.get('@breadcrumbs')
.find('post-breadcrumb-item[selected]:not([selected="false"])')
.filter((_, el) => !el.closest('.invisible'))
.should('match', '[standalone]:not([standalone="false"])');
});

it('should restore all items once space is available again', () => {
cy.viewport(120, 400);
cy.get('@breadcrumbs').shadow().find('nav:not(.invisible) .home-menu').should('exist');

cy.viewport(1920, 800);
cy.get('@breadcrumbs').find('post-breadcrumb-item[variant="menuitem"]').should('not.exist');
cy.get('@breadcrumbs').shadow().find('nav:not(.invisible) .home-menu').should('not.exist');
cy.get('@breadcrumbs')
.find('post-breadcrumb-item[selected]:not([selected="false"])')
.filter((_, el) => !el.closest('.invisible'))
.should('not.match', '[standalone]:not([standalone="false"])');
});
});
});

describe('home link', () => {
describe('client-side routing (slotted anchor)', () => {
beforeEach(() => {
Expand All @@ -55,12 +199,8 @@ describe('breadcrumbs', () => {
});

it('should render the slotted anchor instead of the internal one built from home-url', () => {
// Scoped to the visible nav, since the off-screen clone used for overflow measurement
// legitimately embeds a copy of the slotted anchor to measure its width.
cy.get('@breadcrumbs')
.shadow()
.find('nav:not(.invisible) a[href="/"]')
.should('not.exist');
// Scoped to the visible nav โ€” the off-screen measurement clone also embeds a copy.
cy.get('@breadcrumbs').shadow().find('nav:not(.invisible) a[href="/"]').should('not.exist');
cy.get('@breadcrumbs').children('a[slot="home"]').should('exist').and('have.attr', 'href');
});
});
Expand All @@ -84,5 +224,80 @@ describe('breadcrumbs', () => {
});
});
});

describe('segment specific breadcrumbs', () => {
beforeEach(() => {
cy.getComponent('breadcrumbs', BREADCRUMBS_ID, 'custom-home-text');
cy.get('post-breadcrumbs[data-hydrated]', { timeout: 30000 }).as('breadcrumbs');
});

it('should hide the home icon and display text-home visibly when show-home-text is true', () => {
cy.viewport(2000, 400);
cy.get('@breadcrumbs')
.shadow()
.find('nav:not(.invisible) .home post-icon')
.should('not.exist');
cy.get('@breadcrumbs')
.shadow()
.find('nav:not(.invisible) .home span')
.should('not.have.class', 'visually-hidden')
.and('have.text', 'Private customers');
});

it('should restore the default home icon when show-home-text is set back to false', () => {
cy.viewport(2000, 400);
cy.get('@breadcrumbs').invoke('removeAttr', 'show-home-text');
cy.get('@breadcrumbs').shadow().find('nav:not(.invisible) .home post-icon').should('exist');
cy.get('@breadcrumbs')
.shadow()
.find('nav:not(.invisible) .home span')
.should('have.class', 'visually-hidden');
});

it('should render the home item in full, not wrapped, when there is enough space for it', () => {
cy.viewport(2000, 400);
cy.get('@breadcrumbs')
.shadow()
.find('nav:not(.invisible) .home')
.should('exist')
.and('not.have.class', 'home-menu');
});

it('should never truncate the last (selected) segment, wrapping it instead', () => {
cy.viewport(120, 400);
cy.get('@breadcrumbs')
.find('post-breadcrumb-item[selected]:not([selected="false"])')
.shadow()
.find('a, span')
.then($selected => {
expect($selected.get(0).scrollWidth).to.be.at.most($selected.get(0).clientWidth + 1);
});
});

it('should collapse the middle segments into the overflow menu when there is not enough space for them', () => {
cy.viewport(400, 400);
cy.get('@breadcrumbs')
.find('post-breadcrumb-item[variant="menuitem"]')
.should('have.length.greaterThan', 0);

cy.get('@breadcrumbs')
.find('post-breadcrumb-item[selected]:not([selected="false"])')
.should('not.have.attr', 'variant', 'menuitem');
});

it('should collapse the home item at a width no narrower than where the last item starts wrapping', () => {
assertHomeCollapsesNoLaterThanLastItemWraps();
});

it('should only wrap the last item once home has also collapsed and there is still no room', () => {
cy.viewport(120, 400);

cy.get('@breadcrumbs').shadow().find('nav:not(.invisible) .home-menu').should('exist');
cy.get('@breadcrumbs')
.find('post-breadcrumb-item[selected]:not([selected="false"])')
.filter((_, el) => !el.closest('.invisible'))
.should('match', '[standalone]:not([standalone="false"])');
});
});
});
});
Loading
Loading