From a74d0fe1a0bbfc709a829a4f1c855b29abc6c56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aliz=C3=A9=20Debray?= Date: Tue, 2 Jul 2024 15:49:32 +0200 Subject: [PATCH 01/15] feat(components, components-angular): update the post-collapsible collapsed property --- .changeset/plenty-experts-admire.md | 7 +++++++ .changeset/thin-eyes-join.md | 5 +++++ .../src/app/routes/home/home.component.html | 11 +++++++++- .../src/app/routes/home/home.component.ts | 4 +++- packages/components/src/components.d.ts | 4 ++-- .../post-collapsible/post-collapsible.tsx | 14 ++++++------- .../collapsible/collapsible.stories.ts | 20 ++++++++++++++++++- 7 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 .changeset/plenty-experts-admire.md create mode 100644 .changeset/thin-eyes-join.md diff --git a/.changeset/plenty-experts-admire.md b/.changeset/plenty-experts-admire.md new file mode 100644 index 0000000000..4f67bd39ea --- /dev/null +++ b/.changeset/plenty-experts-admire.md @@ -0,0 +1,7 @@ +--- +'@swisspost/design-system-documentation': patch +'@swisspost/design-system-components': patch +'@swisspost/design-system-components-angular': patch +--- + +Updated the collapsed property to toggle the visibility of the post-collapsible element throughout the component lifecycle, rather than only initially. diff --git a/.changeset/thin-eyes-join.md b/.changeset/thin-eyes-join.md new file mode 100644 index 0000000000..9610fd154d --- /dev/null +++ b/.changeset/thin-eyes-join.md @@ -0,0 +1,5 @@ +--- +'@swisspost/design-system-components-angular-workspace': patch +--- + +Added a toggle button for the `post-collapsible`. diff --git a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html index 58327c6fe3..17fb5671e5 100644 --- a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html +++ b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html @@ -30,7 +30,16 @@

Post Card-Control

Post Collapsible

- + +

Contentus momentus vero siteos et accusam iretea et justo.

diff --git a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts index b795b9fdf8..f454e213db 100644 --- a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts +++ b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts @@ -4,4 +4,6 @@ import { Component } from '@angular/core'; selector: 'home-page', templateUrl: './home.component.html', }) -export class HomeComponent {} +export class HomeComponent { + isCollapsed = false; +} diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index c4a14afe4f..bbd4b74064 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -126,7 +126,7 @@ export namespace Components { } interface PostCollapsible { /** - * If `true`, the element is initially collapsed otherwise it is displayed. + * If `true`, the element is collapsed otherwise it is displayed. */ "collapsed"?: boolean; /** @@ -600,7 +600,7 @@ declare namespace LocalJSX { } interface PostCollapsible { /** - * If `true`, the element is initially collapsed otherwise it is displayed. + * If `true`, the element is collapsed otherwise it is displayed. */ "collapsed"?: boolean; /** diff --git a/packages/components/src/components/post-collapsible/post-collapsible.tsx b/packages/components/src/components/post-collapsible/post-collapsible.tsx index 41a29b4323..97a8fac200 100644 --- a/packages/components/src/components/post-collapsible/post-collapsible.tsx +++ b/packages/components/src/components/post-collapsible/post-collapsible.tsx @@ -33,17 +33,19 @@ export class PostCollapsible { @State() id: string; /** - * If `true`, the element is initially collapsed otherwise it is displayed. + * If `true`, the element is collapsed otherwise it is displayed. */ @Prop() readonly collapsed?: boolean = false; @Watch('collapsed') - validateCollapsed(newValue = this.collapsed) { + collapsedChange(collapsed = this.collapsed) { checkEmptyOrType( - newValue, + collapsed, 'boolean', 'The `collapsed` property of the `post-collapsible` must be a boolean.', ); + + void this.toggle(!collapsed); } /** @@ -53,16 +55,12 @@ export class PostCollapsible { */ @Event() postToggle: EventEmitter; - connectedCallback() { - this.validateCollapsed(); - } - componentWillRender() { this.id = this.host.id || `c${crypto.randomUUID()}`; } componentDidLoad() { - if (this.collapsed) void this.toggle(false); + this.collapsedChange(); this.isLoaded = true; } diff --git a/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts b/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts index ad58063b28..3a02749bdf 100644 --- a/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts +++ b/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts @@ -1,3 +1,4 @@ +import { useArgs } from '@storybook/preview-api'; import { StoryContext, StoryFn, StoryObj } from '@storybook/web-components'; import { html } from 'lit'; import { spreadArgs } from '@/utils'; @@ -61,8 +62,25 @@ function externalControls(story: StoryFn, context: StoryContext) { } //RENDERER +let ignoreToggle = true; function renderCollapsible(args: Partial) { - return html` `; + const [_, updateArgs] = useArgs(); + const handleToggle = (e: CustomEvent) => { + if (ignoreToggle) return; + + const collapsed = !e.detail; + updateArgs({ collapsed }); + }; + + // ignore the first toggle event after a collapsed arg update + ignoreToggle = true; + setTimeout(() => { + ignoreToggle = false; + }, 200); + + return html` + + `; } // STORIES From 6b8095d03bc799baff1dc94500ca461ccdd2c5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aliz=C3=A9=20Debray?= Date: Tue, 2 Jul 2024 19:13:46 +0200 Subject: [PATCH 02/15] feat(components): add a post-collapsible-trigger --- .changeset/wild-bees-laugh.md | 8 ++ .../src/app/routes/home/home.component.html | 5 +- packages/components/src/components.d.ts | 21 +++++ .../post-collapsible-trigger.tsx | 82 +++++++++++++++++++ .../post-collapsible-trigger/readme.md | 17 ++++ packages/components/src/index.ts | 1 + packages/components/src/utils/is-focusable.ts | 28 +++++++ .../collapsible/collapsible.docs.mdx | 12 +-- .../collapsible/collapsible.stories.ts | 51 ++++-------- 9 files changed, 180 insertions(+), 45 deletions(-) create mode 100644 .changeset/wild-bees-laugh.md create mode 100644 packages/components/src/components/post-collapsible-trigger/post-collapsible-trigger.tsx create mode 100644 packages/components/src/components/post-collapsible-trigger/readme.md create mode 100644 packages/components/src/utils/is-focusable.ts diff --git a/.changeset/wild-bees-laugh.md b/.changeset/wild-bees-laugh.md new file mode 100644 index 0000000000..25c1d126a9 --- /dev/null +++ b/.changeset/wild-bees-laugh.md @@ -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`. diff --git a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html index 58327c6fe3..57d7de325f 100644 --- a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html +++ b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html @@ -30,7 +30,10 @@

Post Card-Control

Post Collapsible

- + + + +

Contentus momentus vero siteos et accusam iretea et justo.

diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index c4a14afe4f..f3c90847e1 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -134,6 +134,12 @@ export namespace Components { */ "toggle": (open?: boolean) => Promise; } + interface PostCollapsibleTrigger { + /** + * Link the trigger to a collapsible with this id + */ + "for": string; + } /** * @class PostIcon - representing a stencil component */ @@ -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 */ @@ -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; @@ -608,6 +621,12 @@ declare namespace LocalJSX { */ "onPostToggle"?: (event: PostCollapsibleCustomEvent) => void; } + interface PostCollapsibleTrigger { + /** + * Link the trigger to a collapsible with this id + */ + "for"?: string; + } /** * @class PostIcon - representing a stencil component */ @@ -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; @@ -770,6 +790,7 @@ declare module "@stencil/core" { */ "post-card-control": LocalJSX.PostCardControl & JSXBase.HTMLAttributes; "post-collapsible": LocalJSX.PostCollapsible & JSXBase.HTMLAttributes; + "post-collapsible-trigger": LocalJSX.PostCollapsibleTrigger & JSXBase.HTMLAttributes; /** * @class PostIcon - representing a stencil component */ diff --git a/packages/components/src/components/post-collapsible-trigger/post-collapsible-trigger.tsx b/packages/components/src/components/post-collapsible-trigger/post-collapsible-trigger.tsx new file mode 100644 index 0000000000..b5e6b3bc98 --- /dev/null +++ b/packages/components/src/components/post-collapsible-trigger/post-collapsible-trigger.tsx @@ -0,0 +1,82 @@ +import { Component, Element, Host, h, Prop, Listen, Watch } from '@stencil/core'; +import { version } from 'typescript'; +import { isFocusable } from '@/utils/is-focusable'; +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') + setAriaControls() { + 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); + } + + componentDidLoad() { + const firstChild = this.host.children[0]; + if (firstChild && firstChild.nodeType === Node.ELEMENT_NODE) { + this.trigger = firstChild as HTMLElement; + } else { + this.trigger = this.host; + } + + // Ensure trigger is focusable + if (!isFocusable(this.trigger)) { + this.trigger.setAttribute('tabindex', '0'); + } + + // Ensure trigger has correct role + if (this.trigger.localName !== 'button') { + this.trigger.setAttribute('role', 'button'); + } + + this.setAriaControls(); + } + + @Listen('pointerdown') + handlePointerDown(e: Event) { + if (e.target === this.trigger) void this.toggleCollapsible(); + } + + // see example from Stencil docs: https://stenciljs.com/docs/events#keyboard-events + // eslint-disable-next-line @stencil-community/prefer-vdom-listener + @Listen('keydown') + handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter' || e.key === ' ') this.handlePointerDown(e); + } + + 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; + } + + render() { + return ( + + + + ); + } +} diff --git a/packages/components/src/components/post-collapsible-trigger/readme.md b/packages/components/src/components/post-collapsible-trigger/readme.md new file mode 100644 index 0000000000..b65a8e2b63 --- /dev/null +++ b/packages/components/src/components/post-collapsible-trigger/readme.md @@ -0,0 +1,17 @@ +# post-collapsible-trigger + + + + + + +## 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/)* diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 6ad5b611b6..0130213e14 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -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'; diff --git a/packages/components/src/utils/is-focusable.ts b/packages/components/src/utils/is-focusable.ts new file mode 100644 index 0000000000..66aa7d74dc --- /dev/null +++ b/packages/components/src/utils/is-focusable.ts @@ -0,0 +1,28 @@ +const focusableSelector = `:where(${[ + 'button', + 'input:not([type="hidden"])', + '[tabindex]', + 'select', + 'textarea', + '[contenteditable]', + 'a[href]', + 'iframe', + 'audio[controls]', + 'video[controls]', + 'area[href]', + 'details > summary:first-of-type', +].join(',')})`; + +const focusDisablingSelector = `:where(${[ + '[inert]', + '[inert] *', + ':disabled', + 'dialog:not([open]) *', + '[popover]:not(:popover-open) *', + 'details:not([open]) > *:not(details > summary:first-of-type)', + 'details:not([open]) > *:not(details > summary:first-of-type) *', +].join(',')})`; + +export const isFocusable = (element: Element) => { + return element?.matches(focusableSelector) && !element?.matches(focusDisablingSelector); +}; diff --git a/packages/documentation/src/stories/components/collapsible/collapsible.docs.mdx b/packages/documentation/src/stories/components/collapsible/collapsible.docs.mdx index ab60064f56..60f4145bda 100644 --- a/packages/documentation/src/stories/components/collapsible/collapsible.docs.mdx +++ b/packages/documentation/src/stories/components/collapsible/collapsible.docs.mdx @@ -24,19 +24,13 @@ To make the collapsible content hidden by default, just use the `collapsible="tr -### Custom Trigger +### Programmatic Toggle -The `` component offers a `.toggle()` method that allows to trigger the collapse programmatically. +The `` component offers a `.toggle()` method that allows you to trigger the collapse programmatically. This method is asynchronous and returns a promise that resolves with the current open state. -It optionally takes a boolean parameter that forces open when `true` or close when `false`. +It optionally takes a boolean parameter: `true` forces it open, and `false` forces it closed. - -To ensure good accessibility, identify the collapsible with an `id`, -then add an `aria-controls` attribute to your control element referencing this `id`. -Also make sure to add an `aria-expanded` attribute to the control element: -if the collapsible element is closed, the attribute on the control element must have a value of `aria-expanded="false"` -and `aria-expanded="true"` otherwise. diff --git a/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts b/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts index ad58063b28..ea034d8e98 100644 --- a/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts +++ b/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts @@ -2,6 +2,7 @@ import { StoryContext, StoryFn, StoryObj } from '@storybook/web-components'; import { html } from 'lit'; import { spreadArgs } from '@/utils'; import { MetaComponent } from '@root/types'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; const meta: MetaComponent = { id: '6a91848c-16ec-4a23-bc45-51c797b5b2c3', @@ -9,7 +10,7 @@ const meta: MetaComponent = { tags: ['package:WebComponents'], component: 'post-collapsible', render: renderCollapsible, - decorators: [externalControls], + decorators: [gap], parameters: { badges: [], controls: { @@ -25,46 +26,26 @@ const meta: MetaComponent = { export default meta; // DECORATORS -function externalControls(story: StoryFn, context: StoryContext) { - const { args, canvasElement } = context; - const togglerId = `button--${context.id}`; - - let collapsible!: HTMLPostCollapsibleElement; - let toggler!: HTMLButtonElement; - setTimeout(async () => { - collapsible = canvasElement.querySelector('post-collapsible') as HTMLPostCollapsibleElement; - toggler = canvasElement.querySelector(`#${togglerId}`) as HTMLButtonElement; - - await collapsible.componentOnReady(); - - toggler.setAttribute('aria-controls', collapsible.id); - }); - - const toggle = async () => { - const isOpen = await collapsible.toggle(); - toggler.setAttribute('aria-expanded', String(isOpen)); - }; +function gap(story: StoryFn, context: StoryContext) { + return html`
${story(context.args, context)}
`; +} +//RENDERER +function renderCollapsible( + { innerHTML, ...args }: Partial, + context: StoryContext, +) { return html` - + + + - ${story(args, context)} + + ${unsafeHTML(innerHTML)} + `; } -//RENDERER -function renderCollapsible(args: Partial) { - return html` `; -} - // STORIES type Story = StoryObj; From 7e1bf7705671b587aa0cd215f3306c1b004da309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aliz=C3=A9=20Debray?= Date: Wed, 3 Jul 2024 14:45:00 +0200 Subject: [PATCH 03/15] fix e2e --- .../components/cypress/e2e/collapsible.cy.ts | 72 ++++++++++++------- .../components/cypress/support/commands.ts | 10 ++- .../components/cypress/support/index.d.ts | 1 + .../post-collapsible-trigger.tsx | 25 ++++--- .../post-collapsible/post-collapsible.scss | 3 - .../post-collapsible/post-collapsible.tsx | 16 +---- .../collapsible.snapshot.stories.ts | 32 +++++---- 7 files changed, 87 insertions(+), 72 deletions(-) diff --git a/packages/components/cypress/e2e/collapsible.cy.ts b/packages/components/cypress/e2e/collapsible.cy.ts index f7d2d8a5b7..5144890858 100644 --- a/packages/components/cypress/e2e/collapsible.cy.ts +++ b/packages/components/cypress/e2e/collapsible.cy.ts @@ -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`); }); }); }); diff --git a/packages/components/cypress/support/commands.ts b/packages/components/cypress/support/commands.ts index 29e8f58b8c..20f89a741e 100644 --- a/packages/components/cypress/support/commands.ts +++ b/packages/components/cypress/support/commands.ts @@ -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(); }); diff --git a/packages/components/cypress/support/index.d.ts b/packages/components/cypress/support/index.d.ts index d7b6a2cd32..96fbc79c05 100644 --- a/packages/components/cypress/support/index.d.ts +++ b/packages/components/cypress/support/index.d.ts @@ -2,6 +2,7 @@ declare global { namespace Cypress { interface Chainable { getComponent(component: string, id: string, story?: string): Chainable; + getComponents(id: string, story: string, ...component: string[]): Chainable; getSnapshots(component: string): Chainable; checkAriaExpanded( controlledElementSelector: string, diff --git a/packages/components/src/components/post-collapsible-trigger/post-collapsible-trigger.tsx b/packages/components/src/components/post-collapsible-trigger/post-collapsible-trigger.tsx index b5e6b3bc98..9afaf00a64 100644 --- a/packages/components/src/components/post-collapsible-trigger/post-collapsible-trigger.tsx +++ b/packages/components/src/components/post-collapsible-trigger/post-collapsible-trigger.tsx @@ -1,4 +1,4 @@ -import { Component, Element, Host, h, Prop, Listen, Watch } from '@stencil/core'; +import { Component, Element, Prop, Listen, Watch } from '@stencil/core'; import { version } from 'typescript'; import { isFocusable } from '@/utils/is-focusable'; import { checkNonEmpty, checkType } from '@/utils'; @@ -17,15 +17,22 @@ export class PostCollapsibleTrigger { @Prop() for: string; @Watch('for') - setAriaControls() { + 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); + if (this.trigger) { + this.trigger.setAttribute('aria-controls', this.for); + + const isOpen = !this.collapsible?.collapsed; + if (isOpen !== undefined) this.trigger.setAttribute('aria-expanded', `${isOpen}`); + } } - componentDidLoad() { + componentWillLoad() { + this.host.setAttribute('data-version', version); + const firstChild = this.host.children[0]; if (firstChild && firstChild.nodeType === Node.ELEMENT_NODE) { this.trigger = firstChild as HTMLElement; @@ -43,7 +50,7 @@ export class PostCollapsibleTrigger { this.trigger.setAttribute('role', 'button'); } - this.setAriaControls(); + this.setAriaAttributes(); } @Listen('pointerdown') @@ -71,12 +78,4 @@ export class PostCollapsibleTrigger { return null; } - - render() { - return ( - - - - ); - } } diff --git a/packages/components/src/components/post-collapsible/post-collapsible.scss b/packages/components/src/components/post-collapsible/post-collapsible.scss index 0aaf688255..20947d5cd1 100644 --- a/packages/components/src/components/post-collapsible/post-collapsible.scss +++ b/packages/components/src/components/post-collapsible/post-collapsible.scss @@ -1,7 +1,4 @@ :host { display: block; -} - -.collapse { overflow: hidden; } diff --git a/packages/components/src/components/post-collapsible/post-collapsible.tsx b/packages/components/src/components/post-collapsible/post-collapsible.tsx index 41a29b4323..56ca965b7f 100644 --- a/packages/components/src/components/post-collapsible/post-collapsible.tsx +++ b/packages/components/src/components/post-collapsible/post-collapsible.tsx @@ -7,7 +7,6 @@ import { Host, Method, Prop, - State, Watch, } from '@stencil/core'; import { version } from '@root/package.json'; @@ -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. */ @@ -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; @@ -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(); @@ -91,10 +83,8 @@ export class PostCollapsible { render() { return ( - -
(this.collapsible = el)}> - -
+ + ); } diff --git a/packages/documentation/src/stories/components/collapsible/collapsible.snapshot.stories.ts b/packages/documentation/src/stories/components/collapsible/collapsible.snapshot.stories.ts index 38e0585172..552139bb72 100644 --- a/packages/documentation/src/stories/components/collapsible/collapsible.snapshot.stories.ts +++ b/packages/documentation/src/stories/components/collapsible/collapsible.snapshot.stories.ts @@ -2,13 +2,12 @@ import { html } from 'lit'; import type { Args, StoryContext, StoryObj } from '@storybook/web-components'; import { bombArgs } from '@/utils'; -import meta, { Default } from './collapsible.stories'; +import meta from './collapsible.stories'; const { id, ...metaWithoutId } = meta; export default { ...metaWithoutId, - decorators: [], title: 'Snapshots', }; @@ -16,21 +15,26 @@ type Story = StoryObj; export const Collapsible: Story = { render: (_args: Args, context: StoryContext) => { - const templateVariants = bombArgs({ - collapsed: [false, true], - }).map((args: Args) => { - return html` -
-

collapsed: ${args.collapsed}

- ${meta.render?.({ ...context.args, ...Default.args, ...args }, context)} -
- `; - }); - return html`
${['white', 'dark'].map( - bg => html`
${templateVariants}
`, + bg => html` +
+ ${bombArgs({ + collapsed: [false, true], + }).map( + (args: Args, i: number) => html` +
+

collapsed: ${args.collapsed}

+ ${meta.render?.( + { ...context.args, ...args }, + { ...context, id: `${context.id}-${bg}-${i}` }, + )} +
+ `, + )} +
+ `, )}
`; From 9e21f18cca60b41caa068736dd429c6309048568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aliz=C3=A9=20Debray?= Date: Wed, 3 Jul 2024 17:09:15 +0200 Subject: [PATCH 04/15] Make "collapsed" mutable --- .../post-collapsible/post-collapsible.tsx | 15 ++++++++------- .../src/components/post-collapsible/readme.md | 6 +++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/components/src/components/post-collapsible/post-collapsible.tsx b/packages/components/src/components/post-collapsible/post-collapsible.tsx index 09b61a185b..ebd7fdae20 100644 --- a/packages/components/src/components/post-collapsible/post-collapsible.tsx +++ b/packages/components/src/components/post-collapsible/post-collapsible.tsx @@ -31,17 +31,17 @@ export class PostCollapsible { /** * If `true`, the element is collapsed otherwise it is displayed. */ - @Prop() readonly collapsed?: boolean = false; + @Prop({ mutable: true }) collapsed?: boolean = false; @Watch('collapsed') - collapsedChange(collapsed = this.collapsed) { + collapsedChange() { checkEmptyOrType( - collapsed, + this.collapsed, 'boolean', 'The `collapsed` property of the `post-collapsible` must be a boolean.', ); - void this.toggle(!collapsed); + void this.toggle(!this.collapsed); } /** @@ -68,8 +68,9 @@ export class PostCollapsible { async toggle(open = !this.isOpen): Promise { if (open === this.isOpen) return open; - this.isOpen = !this.isOpen; - if (this.isLoaded) this.postToggle.emit(this.isOpen); + this.isOpen = open; + this.collapsed = !open; + if (this.isLoaded) this.postToggle.emit(open); const animation = open ? expand(this.host) : collapse(this.host); @@ -79,7 +80,7 @@ export class PostCollapsible { animation.commitStyles(); - return this.isOpen; + return open; } render() { diff --git a/packages/components/src/components/post-collapsible/readme.md b/packages/components/src/components/post-collapsible/readme.md index 4955e4debc..527f26e7f7 100644 --- a/packages/components/src/components/post-collapsible/readme.md +++ b/packages/components/src/components/post-collapsible/readme.md @@ -7,9 +7,9 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ----------- | ----------- | ------------------------------------------------------------------------ | --------- | ------- | -| `collapsed` | `collapsed` | If `true`, the element is initially collapsed otherwise it is displayed. | `boolean` | `false` | +| Property | Attribute | Description | Type | Default | +| ----------- | ----------- | -------------------------------------------------------------- | --------- | ------- | +| `collapsed` | `collapsed` | If `true`, the element is collapsed otherwise it is displayed. | `boolean` | `false` | ## Events From 5b8d69d3d94b15e66d11f8fc7ee90a3ec6052d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aliz=C3=A9=20Debray?= Date: Wed, 3 Jul 2024 17:12:30 +0200 Subject: [PATCH 05/15] fix accordion e2e --- packages/components/cypress/e2e/accordion.cy.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/components/cypress/e2e/accordion.cy.ts b/packages/components/cypress/e2e/accordion.cy.ts index 9d8bdd7cc7..372947bb72 100644 --- a/packages/components/cypress/e2e/accordion.cy.ts +++ b/packages/components/cypress/e2e/accordion.cy.ts @@ -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', () => { @@ -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'); }); }); }); From 228c7a2b86e871b7db7587322068239e1bfa1de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aliz=C3=A9=20Debray?= Date: Thu, 4 Jul 2024 11:11:52 +0200 Subject: [PATCH 06/15] fix nested accordions --- .../src/components/post-collapsible/post-collapsible.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/components/src/components/post-collapsible/post-collapsible.tsx b/packages/components/src/components/post-collapsible/post-collapsible.tsx index ebd7fdae20..fde887cfe5 100644 --- a/packages/components/src/components/post-collapsible/post-collapsible.tsx +++ b/packages/components/src/components/post-collapsible/post-collapsible.tsx @@ -51,11 +51,8 @@ export class PostCollapsible { */ @Event() postToggle: EventEmitter; - connectedCallback() { - this.collapsedChange(); - } - componentDidLoad() { + this.collapsedChange(); this.isLoaded = true; } From 8cf0f5dc10896ede70918fa91432dc0127c5b444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aliz=C3=A9=20Debray?= Date: Thu, 4 Jul 2024 14:50:08 +0200 Subject: [PATCH 07/15] Apply changes to the post-accordion-item --- .changeset/plenty-experts-admire.md | 2 +- packages/components/src/components.d.ts | 4 ++-- .../post-accordion-item/post-accordion-item.tsx | 15 +++++++-------- .../src/components/post-accordion-item/readme.md | 2 +- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.changeset/plenty-experts-admire.md b/.changeset/plenty-experts-admire.md index 4f67bd39ea..8ff713ade2 100644 --- a/.changeset/plenty-experts-admire.md +++ b/.changeset/plenty-experts-admire.md @@ -4,4 +4,4 @@ '@swisspost/design-system-components-angular': patch --- -Updated the collapsed property to toggle the visibility of the post-collapsible element throughout the component lifecycle, rather than only initially. +Updated the `collapsed` property of the `post-collapsible` and `post-accordion-item` to toggle the content visibility throughout the component lifecycle, rather than only initially. diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index 8f106dc5ad..d5575c5220 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -36,7 +36,7 @@ export namespace Components { } interface PostAccordionItem { /** - * If `true`, the element is initially collapsed otherwise it is displayed. + * If `true`, the element is collapsed otherwise it is displayed. */ "collapsed"?: boolean; /** @@ -527,7 +527,7 @@ declare namespace LocalJSX { } interface PostAccordionItem { /** - * If `true`, the element is initially collapsed otherwise it is displayed. + * If `true`, the element is collapsed otherwise it is displayed. */ "collapsed"?: boolean; /** diff --git a/packages/components/src/components/post-accordion-item/post-accordion-item.tsx b/packages/components/src/components/post-accordion-item/post-accordion-item.tsx index 7f6c2c1e73..9a8d20558d 100644 --- a/packages/components/src/components/post-accordion-item/post-accordion-item.tsx +++ b/packages/components/src/components/post-accordion-item/post-accordion-item.tsx @@ -19,12 +19,11 @@ export class PostAccordionItem { @Element() host: HTMLPostAccordionItemElement; @State() id: string; - @State() isOpen: boolean; /** - * If `true`, the element is initially collapsed otherwise it is displayed. + * If `true`, the element is collapsed otherwise it is displayed. */ - @Prop() readonly collapsed?: boolean = false; + @Prop({ mutable: true }) collapsed?: boolean = false; /** * Defines the hierarchical level of the accordion item header within the headings structure. @@ -46,14 +45,14 @@ export class PostAccordionItem { } componentWillLoad() { - this.isOpen = !this.collapsed; this.id = this.host.id || `a${crypto.randomUUID()}`; } - @Listen('postToggle') + // capture to make sure the "collapsed" property is updated before the event is consumed + @Listen('postToggle', { capture: true }) onCollapseToggle(event: CustomEvent): void { if ((event.target as HTMLElement).localName === 'post-accordion-item') { - this.isOpen = event.detail; + this.collapsed = !event.detail; } } @@ -74,8 +73,8 @@ export class PostAccordionItem {