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 all 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').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 @@ -393,6 +403,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 +506,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 +625,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 @@ -747,6 +770,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 +794,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,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