Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(components): add a post-collapsible-trigger #3209

Merged
merged 17 commits into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
8 changes: 8 additions & 0 deletions .changeset/wild-bees-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@swisspost/design-system-documentation': minor
'@swisspost/design-system-components': minor
'@swisspost/design-system-components-angular': minor
'@swisspost/design-system-components-react': minor
---

Added a `post-collapsible-trigger` component to properly handle the role, ARIA attributes, and event listeners for elements that toggle a `post-collapsible`.
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ <h2>Post Card-Control</h2>

<div class="my-4">
<h2>Post Collapsible</h2>
<post-collapsible>
<post-collapsible-trigger for="angular-collapsible">
<button class="btn btn-secondary mb-mini">Toggle</button>
</post-collapsible-trigger>
<post-collapsible id="angular-collapsible">
<p>Contentus momentus vero siteos et accusam iretea et justo.</p>
</post-collapsible>
</div>
Expand Down
10 changes: 5 additions & 5 deletions packages/components/cypress/e2e/accordion.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ describe('accordion', () => {
});

it('should only show the first element as expanded', () => {
cy.get('@collapsibles').first().find('.collapse').should('be.visible');
cy.get('@collapsibles').first().shadow().find('post-collapsible').should('be.visible');
});

it('should show the last element as expanded after clicking it', () => {
cy.get('@collapsibles').last().click();
cy.get('@collapsibles').last().find('.collapse').should('be.visible');
cy.get('@collapsibles').last().shadow().find('post-collapsible').should('be.visible');
});

it('should not show the first element as expanded after clicking the last element', () => {
cy.get('@collapsibles').last().click();
cy.get('@collapsibles').first().find('.collapse').should('be.hidden');
cy.get('@collapsibles').first().shadow().find('post-collapsible').should('be.hidden');
});

it('should propagate "postToggle" event from post-accordion-item on post-accordion', () => {
Expand Down Expand Up @@ -73,12 +73,12 @@ describe('accordion', () => {

it('should show the last element as expanded after clicking it', () => {
cy.get('@collapsibles').last().click();
cy.get('@collapsibles').last().find('.collapse').should('be.visible');
cy.get('@collapsibles').last().shadow().find('post-collapsible').should('be.visible');
});

it('should still show the first element as expanded after clicking the last element', () => {
cy.get('@collapsibles').last().click();
cy.get('@collapsibles').first().find('.collapse').should('be.visible');
cy.get('@collapsibles').first().shadow().find('post-collapsible').should('be.visible');
});
});
});
Expand Down
72 changes: 45 additions & 27 deletions packages/components/cypress/e2e/collapsible.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,57 +3,75 @@ const COLLAPSIBLE_ID = '6a91848c-16ec-4a23-bc45-51c797b5b2c3';
describe('collapsible', () => {
describe('default', () => {
beforeEach(() => {
cy.getComponent('collapsible', COLLAPSIBLE_ID);
cy.get('@collapsible').find('.collapse').as('collapse');
cy.get(`#button--${COLLAPSIBLE_ID}--default`).as('toggler');
cy.getComponents(COLLAPSIBLE_ID, 'default', 'post-collapsible', 'post-collapsible-trigger');
cy.get('@collapsible-trigger').find('.btn').as('trigger');
});

it('should render', () => {
it('should have a collapsible', () => {
cy.get('@collapsible').should('exist');
});

it('should have a collapse', () => {
cy.get('@collapse').should('exist');
it('should have a trigger', () => {
cy.get('@trigger').should('exist');
});

it('should have a toggle button', () => {
cy.get('@toggler').should('exist');
it('should show the collapsible', () => {
cy.get('@collapsible').should(`be.visible`);
});

it('should be expanded', () => {
cy.get('@collapse').should(`be.visible`);
it('should set the correct ARIA attribute on the trigger', () => {
cy.get('@collapsible')
.invoke('attr', 'id')
.then(collapsibleId => {
cy.get('@trigger').should('have.attr', 'aria-controls', collapsibleId);
});
cy.get('@trigger').should('have.attr', 'aria-expanded', 'true');
});

it('should be collapsed after clicking on the toggle button once', () => {
cy.get('@toggler').click();
cy.get('@collapse').should(`be.hidden`);
it('should hide the collapsible after clicking on the trigger once', () => {
cy.get('@trigger').click();
cy.get('@collapsible').should(`be.hidden`);
});

it('should be expanded after clicking on the toggle button twice', () => {
cy.get('@toggler').dblclick();
cy.get('@collapse').should(`be.visible`);
it('should update the "aria-expanded" attribute after hiding the collapsible', () => {
cy.get('@trigger').click();
cy.get('@trigger').should('have.attr', 'aria-expanded', 'false');
});

it('should show the collapsible after clicking on the trigger twice', () => {
cy.get('@trigger').dblclick();
cy.get('@collapsible').should(`be.visible`);
});

it('should update the "aria-expanded" attribute after showing the collapsible', () => {
cy.get('@trigger').click();
cy.get('@trigger').should('have.attr', 'aria-expanded', 'true');
});
});

describe('initially collapsed', () => {
beforeEach(() => {
cy.getComponent('collapsible', COLLAPSIBLE_ID, 'initially-collapsed');
cy.get('@collapsible').find('.collapse').as('collapse');
cy.get(`#button--${COLLAPSIBLE_ID}--initially-collapsed`).as('toggler');
cy.getComponents(
COLLAPSIBLE_ID,
'initially-collapsed',
'post-collapsible',
'post-collapsible-trigger',
);
cy.get('@collapsible-trigger').find('.btn').as('trigger');
});

it('should be collapsed', () => {
cy.get('@collapse').should(`be.hidden`);
it('should hide the collapsible', () => {
cy.get('@collapsible').should(`be.hidden`);
});

it('should be expanded after clicking on the toggle button once', () => {
cy.get('@toggler').click();
cy.get('@collapse').should(`be.visible`);
it('should show the collapsible after clicking on the trigger once', () => {
cy.get('@trigger').click();
cy.get('@collapsible').should(`be.visible`);
});

it('should be collapsed after clicking on the toggle button twice', () => {
cy.get('@toggler').dblclick();
cy.get('@collapse').should(`be.hidden`);
it('should hide the collapsible after clicking on the trigger twice', () => {
cy.get('@trigger').dblclick();
cy.get('@collapsible').should(`be.hidden`);
});
});
});
Expand Down
10 changes: 8 additions & 2 deletions packages/components/cypress/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,16 @@ export const isInViewport = function (_chai: Chai.ChaiStatic) {
chai.use(isInViewport);

Cypress.Commands.add('getComponent', (component: string, id: string, story = 'default') => {
cy.getComponents(id, story, component);
});

Cypress.Commands.add('getComponents', (id: string, story: string, ...components: string[]) => {
cy.visit(`/iframe.html?id=${id}--${story}`);

const alias = component.replace(/^post-/, '');
cy.get(`post-${alias}`, { timeout: 30000 }).as(alias);
components.forEach(component => {
const alias = component.replace(/^post-/, '');
cy.get(`post-${alias}.hydrated`, { timeout: 30000 }).as(alias);
});

cy.injectAxe();
});
Expand Down
1 change: 1 addition & 0 deletions packages/components/cypress/support/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ declare global {
namespace Cypress {
interface Chainable {
getComponent(component: string, id: string, story?: string): Chainable<any>;
getComponents(id: string, story: string, ...component: string[]): Chainable<any>;
getSnapshots(component: string): Chainable<any>;
checkAriaExpanded(
controlledElementSelector: string,
Expand Down
21 changes: 21 additions & 0 deletions packages/components/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ export namespace Components {
*/
"toggle": (open?: boolean) => Promise<boolean>;
}
interface PostCollapsibleTrigger {
/**
* Link the trigger to a collapsible with this id
*/
"for": string;
}
/**
* @class PostIcon - representing a stencil component
*/
Expand Down Expand Up @@ -393,6 +399,12 @@ declare global {
prototype: HTMLPostCollapsibleElement;
new (): HTMLPostCollapsibleElement;
};
interface HTMLPostCollapsibleTriggerElement extends Components.PostCollapsibleTrigger, HTMLStencilElement {
}
var HTMLPostCollapsibleTriggerElement: {
prototype: HTMLPostCollapsibleTriggerElement;
new (): HTMLPostCollapsibleTriggerElement;
};
/**
* @class PostIcon - representing a stencil component
*/
Expand Down Expand Up @@ -490,6 +502,7 @@ declare global {
"post-alert": HTMLPostAlertElement;
"post-card-control": HTMLPostCardControlElement;
"post-collapsible": HTMLPostCollapsibleElement;
"post-collapsible-trigger": HTMLPostCollapsibleTriggerElement;
"post-icon": HTMLPostIconElement;
"post-popover": HTMLPostPopoverElement;
"post-popovercontainer": HTMLPostPopovercontainerElement;
Expand Down Expand Up @@ -608,6 +621,12 @@ declare namespace LocalJSX {
*/
"onPostToggle"?: (event: PostCollapsibleCustomEvent<boolean>) => void;
}
interface PostCollapsibleTrigger {
/**
* Link the trigger to a collapsible with this id
*/
"for"?: string;
}
/**
* @class PostIcon - representing a stencil component
*/
Expand Down Expand Up @@ -747,6 +766,7 @@ declare namespace LocalJSX {
"post-alert": PostAlert;
"post-card-control": PostCardControl;
"post-collapsible": PostCollapsible;
"post-collapsible-trigger": PostCollapsibleTrigger;
"post-icon": PostIcon;
"post-popover": PostPopover;
"post-popovercontainer": PostPopovercontainer;
Expand All @@ -770,6 +790,7 @@ declare module "@stencil/core" {
*/
"post-card-control": LocalJSX.PostCardControl & JSXBase.HTMLAttributes<HTMLPostCardControlElement>;
"post-collapsible": LocalJSX.PostCollapsible & JSXBase.HTMLAttributes<HTMLPostCollapsibleElement>;
"post-collapsible-trigger": LocalJSX.PostCollapsibleTrigger & JSXBase.HTMLAttributes<HTMLPostCollapsibleTriggerElement>;
/**
* @class PostIcon - representing a stencil component
*/
Expand Down
gfellerph marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Component, Element, Prop, Watch } from '@stencil/core';
import { version } from 'typescript';
import { checkNonEmpty, checkType } from '@/utils';

@Component({
tag: 'post-collapsible-trigger',
})
export class PostCollapsibleTrigger {
private trigger: HTMLElement | undefined;

@Element() host: HTMLPostCollapsibleTriggerElement;

/**
* Link the trigger to a collapsible with this id
*/
@Prop() for: string;

@Watch('for')
setAriaAttributes() {
checkNonEmpty(this.for, 'The post-collapsible-trigger "for" prop is required.');
checkType(this.for, 'string', 'The post-collapsible-trigger "for" prop should be a id.');

// Add collapsible id to aria-controls
if (this.trigger) {
this.trigger.setAttribute('aria-controls', this.for);

const isOpen = !this.collapsible?.collapsed;
if (isOpen !== undefined) this.trigger.setAttribute('aria-expanded', `${isOpen}`);
}
}

componentWillLoad() {
alizedebray marked this conversation as resolved.
Show resolved Hide resolved
this.host.setAttribute('data-version', version);
this.trigger = this.host.querySelector('button');

if (!this.trigger) throw new Error('The post-collapsible-trigger must contain a button.');
alizedebray marked this conversation as resolved.
Show resolved Hide resolved

this.trigger.addEventListener('click', () => this.toggleCollapsible());
alizedebray marked this conversation as resolved.
Show resolved Hide resolved
this.setAriaAttributes();
}
alizedebray marked this conversation as resolved.
Show resolved Hide resolved

private async toggleCollapsible() {
const isOpen = await this.collapsible?.toggle();
this.trigger.setAttribute('aria-expanded', `${isOpen}`);
}

private get collapsible(): HTMLPostCollapsibleElement | null {
const ref = document.getElementById(this.for);
if (ref && ref.localName === 'post-collapsible') {
return ref as HTMLPostCollapsibleElement;
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# post-collapsible-trigger



<!-- Auto Generated Below -->


## Properties

| Property | Attribute | Description | Type | Default |
| -------- | --------- | ---------------------------------------------- | -------- | ----------- |
| `for` | `for` | Link the trigger to a collapsible with this id | `string` | `undefined` |


----------------------------------------------

*Built with [StencilJS](https://stenciljs.com/)*
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
:host {
display: block;
}

.collapse {
overflow: hidden;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
Host,
Method,
Prop,
State,
Watch,
} from '@stencil/core';
import { version } from '@root/package.json';
Expand All @@ -26,12 +25,9 @@ import { checkEmptyOrType, isMotionReduced } from '@/utils';
export class PostCollapsible {
private isLoaded = false;
private isOpen = true;
private collapsible: HTMLElement;

@Element() host: HTMLPostCollapsibleElement;

@State() id: string;

/**
* If `true`, the element is initially collapsed otherwise it is displayed.
*/
Expand All @@ -57,10 +53,6 @@ export class PostCollapsible {
this.validateCollapsed();
}

componentWillRender() {
this.id = this.host.id || `c${crypto.randomUUID()}`;
}

componentDidLoad() {
if (this.collapsed) void this.toggle(false);
this.isLoaded = true;
Expand All @@ -78,7 +70,7 @@ export class PostCollapsible {
this.isOpen = !this.isOpen;
if (this.isLoaded) this.postToggle.emit(this.isOpen);

const animation = open ? expand(this.collapsible) : collapse(this.collapsible);
const animation = open ? expand(this.host) : collapse(this.host);

if (!this.isLoaded || isMotionReduced()) animation.finish();

Expand All @@ -91,10 +83,8 @@ export class PostCollapsible {

render() {
return (
<Host id={this.id} data-version={version}>
<div class="collapse" id={`${this.id}--collapse`} ref={el => (this.collapsible = el)}>
<slot />
</div>
<Host data-version={version}>
<slot />
</Host>
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { PostAccordionItem } from './components/post-accordion-item/post-accordi
export { PostAlert } from './components/post-alert/post-alert';
export { PostCardControl } from './components/post-card-control/post-card-control';
export { PostCollapsible } from './components/post-collapsible/post-collapsible';
export { PostCollapsibleTrigger } from './components/post-collapsible-trigger/post-collapsible-trigger';
export { PostIcon } from './components/post-icon/post-icon';
export { PostPopover } from './components/post-popover/post-popover';
export { PostPopovercontainer } from './components/post-popovercontainer/post-popovercontainer';
Expand Down
Loading