Skip to content

Commit

Permalink
feat(components): add a post-collapsible-trigger (#3209)
Browse files Browse the repository at this point in the history
Co-authored-by: Philipp Gfeller <[email protected]>
  • Loading branch information
alizedebray and gfellerph authored Jul 18, 2024
1 parent dd67dfe commit b43887d
Show file tree
Hide file tree
Showing 18 changed files with 355 additions and 109 deletions.
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').dblclick();
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
25 changes: 25 additions & 0 deletions packages/components/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ export namespace Components {
*/
"toggle": (open?: boolean) => Promise<boolean>;
}
interface PostCollapsibleTrigger {
/**
* Link the trigger to a post-collapsible with this id
*/
"for": string;
/**
* Update the "aria-controls" and "aria-expanded" attributes on the trigger button
*/
"update": () => Promise<void>;
}
/**
* @class PostIcon - representing a stencil component
*/
Expand Down Expand Up @@ -397,6 +407,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 @@ -494,6 +510,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 @@ -612,6 +629,12 @@ declare namespace LocalJSX {
*/
"onPostToggle"?: (event: PostCollapsibleCustomEvent<boolean>) => void;
}
interface PostCollapsibleTrigger {
/**
* Link the trigger to a post-collapsible with this id
*/
"for"?: string;
}
/**
* @class PostIcon - representing a stencil component
*/
Expand Down Expand Up @@ -755,6 +778,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 @@ -778,6 +802,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Component, Element, Listen, Method, Prop, Watch } from '@stencil/core';
import { version } from 'typescript';
import { checkNonEmpty, checkType, debounce } from '@/utils';
import { PostCollapsibleCustomEvent } from '@/components';

@Component({
tag: 'post-collapsible-trigger',
})
export class PostCollapsibleTrigger {
private trigger?: HTMLButtonElement;
private observer = new MutationObserver(() => this.setTrigger());

@Element() host: HTMLPostCollapsibleTriggerElement;

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

/**
* Set the "aria-controls" and "aria-expanded" attributes on the trigger to match the state of the controlled post-collapsible
*/
@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.');

void this.update();
}

/**
* Initiate a mutation observer that updates the trigger whenever necessary
*/
connectedCallback() {
this.observer.observe(this.host, { childList: true, subtree: true });
}

/**
* Add the "data-version" to the host element and set the trigger
*/
componentDidLoad() {
this.host.setAttribute('data-version', version);
this.setTrigger();

if (!this.trigger) console.warn('The post-collapsible-trigger must contain a button.');
}

/**
* Disconnect the mutation observer
*/
disconnectedCallback() {
this.observer.disconnect();
}

/**
* Update the "aria-expanded" attribute on the trigger anytime the controlled post-collapsible is toggled
*/
@Listen('postToggle', { target: 'document' })
setAriaExpanded(e: PostCollapsibleCustomEvent<boolean>) {
if (!this.trigger || !e.target.isEqualNode(this.collapsible)) return;
this.trigger.setAttribute('aria-expanded', `${e.detail}`);
}

/**
* Update the "aria-controls" and "aria-expanded" attributes on the trigger button
*/
@Method()
async update() {
this.debouncedUpdate();
}

private debouncedUpdate = debounce(() => {
if (!this.trigger) return;

// add the provided id to the aria-controls list
const ariaControls = this.trigger.getAttribute('aria-controls');
if (!ariaControls?.includes(this.for)) {
const newAriaControls = ariaControls ? `${ariaControls} ${this.for}` : this.for;
this.trigger.setAttribute('aria-controls', newAriaControls);
}

// set the aria-expanded to `false` if the controlled collapsible is collapsed or undefined, set it to `true` otherwise
const isCollapsed = this.collapsible?.collapsed;
const newAriaExpanded = isCollapsed !== undefined ? !isCollapsed : undefined;
this.trigger.setAttribute('aria-expanded', `${newAriaExpanded}`);
});

/**
* Toggle the post-collapsible controlled by the trigger
*/
private async toggleCollapsible() {
await this.collapsible?.toggle();
}

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

return null;
}

/**
* Find the button and add the proper event listener and ARIA attributes to it
*/
private setTrigger() {
const trigger = this.host.querySelector('button');
if (!trigger || (this.trigger && trigger.isEqualNode(this.trigger))) return;

this.trigger = trigger;

this.trigger.addEventListener('click', () => this.toggleCollapsible());
this.setAriaAttributes();
}
}
Loading

0 comments on commit b43887d

Please sign in to comment.