Skip to content

Commit b41db4b

Browse files
alizedebrayimagoiqgfellerph
authored
feat(components): add post-tabs component (#1181)
Co-authored-by: Loïc Fürhoff <[email protected]> Co-authored-by: Philipp Gfeller <[email protected]>
1 parent 68ec4b1 commit b41db4b

File tree

25 files changed

+999
-224
lines changed

25 files changed

+999
-224
lines changed

.changeset/shy-cooks-arrive.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@swisspost/design-system-documentation': minor
3+
'@swisspost/design-system-components': minor
4+
'@swisspost/design-system-styles': patch
5+
---
6+
7+
Added a new post-tabs component.

packages/components/cypress/e2e/collapsible.cy.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
describe('collapsible', () => {
22
describe('default', () => {
33
beforeEach(() => {
4-
cy.registerCollapsibleFrom('/iframe.html?id=components-collapsible--default');
4+
cy.getComponent('collapsible');
5+
cy.get('@collapsible').find('.collapse').as('collapse');
56
cy.get('@collapsible').find('.accordion-header').as('header');
67
cy.get('@collapsible').find('.accordion-body').as('body');
78
});
@@ -62,9 +63,8 @@ describe('collapsible', () => {
6263

6364
describe('initially collapsed', () => {
6465
beforeEach(() => {
65-
cy.registerCollapsibleFrom(
66-
'/iframe.html?id=components-collapsible--initially-collapsed',
67-
);
66+
cy.getComponent('collapsible', 'initially-collapsed');
67+
cy.get('@collapsible').find('.collapse').as('collapse');
6868
cy.get('@collapsible').find('.accordion-header').as('header');
6969
});
7070

@@ -89,7 +89,8 @@ describe('collapsible', () => {
8989

9090
describe('custom trigger', () => {
9191
beforeEach(() => {
92-
cy.registerCollapsibleFrom('/iframe.html?id=components-collapsible--custom-trigger');
92+
cy.getComponent('collapsible', 'custom-trigger');
93+
cy.get('@collapsible').find('.collapse').as('collapse');
9394
cy.get('[aria-controls="collapsible-example--custom-trigger"]').as('controls');
9495
cy.get('@controls').contains('Toggle').as('toggle');
9596
cy.get('@controls').contains('Show').as('show');
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
describe('tabs', () => {
2+
describe('default', () => {
3+
beforeEach(() => {
4+
cy.getComponent('tabs');
5+
cy.get('post-tab-header').as('headers');
6+
});
7+
8+
it('should render', () => {
9+
cy.get('@tabs').should('exist');
10+
});
11+
12+
it('should show three tab headers', () => {
13+
cy.get('@headers').should('have.length', 3);
14+
});
15+
16+
it('should only show the first tab header as active', () => {
17+
cy.get('@headers').each(($header, index) => {
18+
cy.wrap($header).find('.active').should(index === 0 ? 'exist' : 'not.exist');
19+
});
20+
});
21+
22+
it('should only show the tab panel associated with the first tab header', () => {
23+
cy.get('post-tab-panel:visible').as('panel');
24+
cy.get('@panel').should('have.length', 1);
25+
cy.get('@headers').first().invoke('attr', 'panel').then(panel => {
26+
cy.get('@panel').invoke('attr', 'name').should('equal', panel);
27+
});
28+
});
29+
30+
it('should activate a clicked tab header and deactivate the tab header that was previously activated', () => {
31+
cy.get('@headers').last().click();
32+
33+
cy.get('@headers').first().find('.active').should('not.exist');
34+
cy.get('@headers').last().find('.active').should('exist');
35+
});
36+
37+
it('should show the panel associated with a clicked tab header and hide the panel that was previously shown', () => {
38+
cy.get('@headers').last().click();
39+
40+
// wait for the fade out animation to complete
41+
cy.wait(200);
42+
43+
cy.get('post-tab-panel:visible').as('panel');
44+
cy.get('@panel').should('have.length', 1);
45+
cy.get('@headers').last().invoke('attr', 'panel').then(panel => {
46+
cy.get('@panel').invoke('attr', 'name').should('equal', panel);
47+
});
48+
});
49+
});
50+
51+
describe('active panel', () => {
52+
beforeEach(() => {
53+
cy.getComponent('tabs', 'active-panel');
54+
cy.get('post-tab-header').as('headers');
55+
cy.get('post-tab-panel:visible').as('panel');
56+
});
57+
58+
it('should only show the requested active tab panel', () => {
59+
cy.get('@panel').should('have.length', 1);
60+
cy.get('@tabs').invoke('attr', 'active-panel').then(activePanel => {
61+
cy.get('@panel').invoke('attr', 'name').should('equal', activePanel);
62+
});
63+
});
64+
65+
it('should show as active only the tab header associated with the requested active tab panel', () => {
66+
cy.get('@tabs').invoke('attr', 'active-panel').then(activePanel => {
67+
cy.get('@headers').each($header => {
68+
cy.wrap($header).invoke('attr', 'panel').then(panel => {
69+
cy.wrap($header).find('.active').should(panel === activePanel ? 'exist' : 'not.exist');
70+
});
71+
});
72+
});
73+
});
74+
});
75+
76+
describe('async', () => {
77+
beforeEach(() => {
78+
cy.getComponent('tabs', 'async');
79+
cy.get('post-tab-header').as('headers');
80+
});
81+
82+
it('should add a tab header', () => {
83+
cy.get('#add-tab').click();
84+
cy.get('@headers').should('have.length', 4);
85+
});
86+
87+
it('should still show the tab panel associated with the first tab header after adding new tab', () => {
88+
cy.get('#add-tab').click();
89+
90+
cy.get('post-tab-panel:visible').as('panel');
91+
cy.get('@panel').should('have.length', 1);
92+
cy.get('@headers').first().invoke('attr', 'panel').then(panel => {
93+
cy.get('@panel').invoke('attr', 'name').should('equal', panel);
94+
});
95+
});
96+
97+
it('should activate the newly added tab header after clicking on it', () => {
98+
cy.get('#add-tab').click();
99+
100+
cy.get('post-tab-header').as('headers');
101+
cy.get('@headers').last().click();
102+
103+
cy.get('@headers').first().find('.active').should('not.exist');
104+
cy.get('@headers').last().find('.active').should('exist');
105+
});
106+
107+
it('should display the tab panel associated with the newly added tab after clicking on it', () => {
108+
cy.get('#add-tab').click();
109+
110+
cy.get('post-tab-header').last().as('new-panel');
111+
cy.get('@new-panel').click();
112+
113+
// wait for the fade out animation to complete
114+
cy.wait(200);
115+
116+
cy.get('post-tab-panel:visible').as('panel');
117+
cy.get('@panel').should('have.length', 1);
118+
cy.get('@new-panel').invoke('attr', 'panel').then(panel => {
119+
cy.get('@panel').invoke('attr', 'name').should('equal', panel);
120+
});
121+
});
122+
123+
it('should remove a tab header', () => {
124+
cy.get('.tab-title.active').then(() => {
125+
cy.get('#remove-active-tab').click();
126+
cy.get('@headers').should('have.length', 2);
127+
});
128+
});
129+
130+
it('should still show an active tab header after removing the active tab', () => {
131+
cy.get('.tab-title.active').then(() => {
132+
cy.get('#remove-active-tab').click();
133+
cy.get('.tab-title.active').should('exist');
134+
});
135+
});
136+
137+
it('should still show a tab panel after removing the active tab', () => {
138+
cy.get('.tab-title.active').then(() => {
139+
cy.get('#remove-active-tab').click();
140+
cy.get('post-tab-panel:visible').should('exist');
141+
});
142+
});
143+
});
144+
});

packages/components/cypress/support/commands.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,9 @@ export const isInViewport = function (_chai: Chai.ChaiStatic) {
4848

4949
chai.use(isInViewport);
5050

51-
Cypress.Commands.add('registerCollapsibleFrom', (url: string) => {
52-
cy.visit(url);
53-
cy.get('post-collapsible').as('collapsible');
54-
cy.get('@collapsible').find('.collapse').as('collapse');
51+
Cypress.Commands.add('getComponent', (component: string, story = 'default') => {
52+
cy.visit(`/iframe.html?id=components-${component}--${story}`);
53+
cy.get(`post-${component}`).as(component);
5554
});
5655

5756
Cypress.Commands.add('checkVisibility', (visibility: 'visible' | 'hidden') => {

packages/components/cypress/support/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
declare global {
22
namespace Cypress {
33
interface Chainable {
4-
registerCollapsibleFrom(url: string): Chainable<any>;
4+
getComponent(component: string, story?: string): Chainable<any>;
55
checkVisibility(visibility: 'visible' | 'hidden'): Chainable<any>;
66
checkAriaExpanded(isExpanded: 'true' | 'false'): Chainable<any>;
77
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const fadeDuration = 200;
2+
const fadedOutKeyFrame = {opacity: '0'};
3+
const fadedInKeyFrame = {opacity: '1'};
4+
5+
export const fadeIn = (el: Element): Animation => el.animate(
6+
[ fadedOutKeyFrame, fadedInKeyFrame ],
7+
{ duration: fadeDuration }
8+
);
9+
10+
export const fadeOut = (el: Element): Animation => el.animate(
11+
[ fadedInKeyFrame, fadedOutKeyFrame ],
12+
{ duration: fadeDuration }
13+
);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './fade';

packages/components/src/components.d.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,32 @@ export namespace Components {
5353
*/
5454
"scale"?: number | null;
5555
}
56+
interface PostTabHeader {
57+
/**
58+
* The name of the panel controlled by the tab header.
59+
*/
60+
"panel": HTMLPostTabPanelElement['name'];
61+
}
62+
interface PostTabPanel {
63+
/**
64+
* The name of the panel, used to associate it with a tab header.
65+
*/
66+
"name": string;
67+
}
68+
interface PostTabs {
69+
/**
70+
* The name of the panel that is initially shown. If not specified, it defaults to the panel associated with the first tab. **Changing this value after initialization has no effect.**
71+
*/
72+
"activePanel": HTMLPostTabPanelElement['name'];
73+
/**
74+
* Shows the panel with the given name and selects its associated tab. Any other panel that was previously shown becomes hidden and its associated tab is unselected.
75+
*/
76+
"show": (panelName: string) => Promise<void>;
77+
}
78+
}
79+
export interface PostTabsCustomEvent<T> extends CustomEvent<T> {
80+
detail: T;
81+
target: HTMLPostTabsElement;
5682
}
5783
declare global {
5884
interface HTMLPostCollapsibleElement extends Components.PostCollapsible, HTMLStencilElement {
@@ -70,9 +96,30 @@ declare global {
7096
prototype: HTMLPostIconElement;
7197
new (): HTMLPostIconElement;
7298
};
99+
interface HTMLPostTabHeaderElement extends Components.PostTabHeader, HTMLStencilElement {
100+
}
101+
var HTMLPostTabHeaderElement: {
102+
prototype: HTMLPostTabHeaderElement;
103+
new (): HTMLPostTabHeaderElement;
104+
};
105+
interface HTMLPostTabPanelElement extends Components.PostTabPanel, HTMLStencilElement {
106+
}
107+
var HTMLPostTabPanelElement: {
108+
prototype: HTMLPostTabPanelElement;
109+
new (): HTMLPostTabPanelElement;
110+
};
111+
interface HTMLPostTabsElement extends Components.PostTabs, HTMLStencilElement {
112+
}
113+
var HTMLPostTabsElement: {
114+
prototype: HTMLPostTabsElement;
115+
new (): HTMLPostTabsElement;
116+
};
73117
interface HTMLElementTagNameMap {
74118
"post-collapsible": HTMLPostCollapsibleElement;
75119
"post-icon": HTMLPostIconElement;
120+
"post-tab-header": HTMLPostTabHeaderElement;
121+
"post-tab-panel": HTMLPostTabPanelElement;
122+
"post-tabs": HTMLPostTabsElement;
76123
}
77124
}
78125
declare namespace LocalJSX {
@@ -119,9 +166,34 @@ declare namespace LocalJSX {
119166
*/
120167
"scale"?: number | null;
121168
}
169+
interface PostTabHeader {
170+
/**
171+
* The name of the panel controlled by the tab header.
172+
*/
173+
"panel"?: HTMLPostTabPanelElement['name'];
174+
}
175+
interface PostTabPanel {
176+
/**
177+
* The name of the panel, used to associate it with a tab header.
178+
*/
179+
"name"?: string;
180+
}
181+
interface PostTabs {
182+
/**
183+
* The name of the panel that is initially shown. If not specified, it defaults to the panel associated with the first tab. **Changing this value after initialization has no effect.**
184+
*/
185+
"activePanel"?: HTMLPostTabPanelElement['name'];
186+
/**
187+
* An event emitted after the active tab changes, when the fade in transition of its associated panel is finished. The payload is the name of the newly shown panel.
188+
*/
189+
"onTabChange"?: (event: PostTabsCustomEvent<HTMLPostTabPanelElement['name']>) => void;
190+
}
122191
interface IntrinsicElements {
123192
"post-collapsible": PostCollapsible;
124193
"post-icon": PostIcon;
194+
"post-tab-header": PostTabHeader;
195+
"post-tab-panel": PostTabPanel;
196+
"post-tabs": PostTabs;
125197
}
126198
}
127199
export { LocalJSX as JSX };
@@ -133,6 +205,9 @@ declare module "@stencil/core" {
133205
* @class PostIcon - representing a stencil component
134206
*/
135207
"post-icon": LocalJSX.PostIcon & JSXBase.HTMLAttributes<HTMLPostIconElement>;
208+
"post-tab-header": LocalJSX.PostTabHeader & JSXBase.HTMLAttributes<HTMLPostTabHeaderElement>;
209+
"post-tab-panel": LocalJSX.PostTabPanel & JSXBase.HTMLAttributes<HTMLPostTabPanelElement>;
210+
"post-tabs": LocalJSX.PostTabs & JSXBase.HTMLAttributes<HTMLPostTabsElement>;
136211
}
137212
}
138213
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@use '@swisspost/design-system-styles/components/tabs/tab-title';
2+
3+
:host {
4+
display: block;
5+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Component, Element, h, Host, Prop, State, Watch } from '@stencil/core';
2+
import { version } from '../../../package.json';
3+
import { checkNonEmpty } from '../../utils';
4+
5+
@Component({
6+
tag: 'post-tab-header',
7+
styleUrl: 'post-tab-header.scss',
8+
shadow: true,
9+
})
10+
export class PostTabHeader {
11+
@Element() host: HTMLPostTabHeaderElement;
12+
13+
@State() tabId: string;
14+
15+
/**
16+
* The name of the panel controlled by the tab header.
17+
*/
18+
@Prop() readonly panel: HTMLPostTabPanelElement['name'];
19+
20+
@Watch('panel')
21+
validateFor(newValue: HTMLPostTabPanelElement['name']) {
22+
checkNonEmpty(newValue, 'The "panel" prop is required for the post-tab-header.');
23+
}
24+
25+
componentWillLoad() {
26+
this.tabId = `tab-${this.host.id || crypto.randomUUID()}`;
27+
}
28+
29+
render() {
30+
return (
31+
<Host data-version={version}>
32+
<li class="nav-item">
33+
<a
34+
aria-selected="false"
35+
class="tab-title nav-link"
36+
href="#"
37+
id={this.tabId}
38+
role="tab"
39+
>
40+
<slot/>
41+
</a>
42+
</li>
43+
</Host>
44+
);
45+
}
46+
}

0 commit comments

Comments
 (0)