diff --git a/packages/components/cypress/e2e/collapsible.cy.ts b/packages/components/cypress/e2e/collapsible.cy.ts index 4714d372e4..38ca2534ab 100644 --- a/packages/components/cypress/e2e/collapsible.cy.ts +++ b/packages/components/cypress/e2e/collapsible.cy.ts @@ -20,7 +20,7 @@ describe('collapsible', () => { }); it('should be expanded', () => { - cy.checkVisibility('visible'); + cy.get('@collapse').should(`be.visible`); }); it('should show the whole body', () => { @@ -37,27 +37,27 @@ describe('collapsible', () => { .then(id => { cy.get('@header').find('button').should('have.attr', 'aria-controls', id); }); - cy.checkAriaExpanded('true'); + cy.checkAriaExpanded('@collapse', 'true'); }); it('should be collapsed after clicking on the header once', () => { cy.get('@header').click(); - cy.checkVisibility('hidden'); + cy.get('@collapse').should(`be.hidden`); }); it("should adapt the header's aria-expanded attribute after collapsing", () => { cy.get('@header').click(); - cy.checkAriaExpanded('false'); + cy.checkAriaExpanded('@collapse', 'false'); }); it('should be expanded after clicking on the header twice', () => { cy.get('@header').dblclick(); - cy.checkVisibility('visible'); + cy.get('@collapse').should(`be.visible`); }); it("should adapt the header's aria-expanded attribute after expanding", () => { cy.get('@header').dblclick(); - cy.checkAriaExpanded('true'); + cy.checkAriaExpanded('@collapse', 'true'); }); }); @@ -69,21 +69,21 @@ describe('collapsible', () => { }); it('should be collapsed', () => { - cy.checkVisibility('hidden'); + cy.get('@collapse').should(`be.hidden`); }); it('should have a correct aria-expanded attribute', () => { - cy.checkAriaExpanded('false'); + cy.checkAriaExpanded('@collapse', 'false'); }); it('should be expanded after clicking on the header once', () => { cy.get('@header').click(); - cy.checkVisibility('visible'); + cy.get('@collapse').should(`be.visible`); }); it('should be collapsed after clicking on the header twice', () => { cy.get('@header').dblclick(); - cy.checkVisibility('hidden'); + cy.get('@collapse').should(`be.hidden`); }); }); @@ -102,37 +102,37 @@ describe('collapsible', () => { }); it('should be expanded', () => { - cy.checkVisibility('visible'); + cy.get('@collapse').should(`be.visible`); }); it('should be collapsed after clicking "Toggle" once', () => { cy.get('@toggle').click(); - cy.checkVisibility('hidden'); + cy.get('@collapse').should(`be.hidden`); }); it('should be expanded after clicking "Toggle" twice', () => { cy.get('@toggle').dblclick(); - cy.checkVisibility('visible'); + cy.get('@collapse').should(`be.visible`); }); it('should be collapsed after clicking "Hide" once', () => { cy.get('@hide').click(); - cy.checkVisibility('hidden'); + cy.get('@collapse').should(`be.hidden`); }); it('should be collapsed after clicking "Hide" twice', () => { cy.get('@hide').dblclick(); - cy.checkVisibility('hidden'); + cy.get('@collapse').should(`be.hidden`); }); it('should be expanded after clicking "Show" once', () => { cy.get('@show').click(); - cy.checkVisibility('visible'); + cy.get('@collapse').should(`be.visible`); }); it('should be expanded after clicking "Show" twice', () => { cy.get('@show').dblclick(); - cy.checkVisibility('visible'); + cy.get('@collapse').should(`be.visible`); }); }); }); diff --git a/packages/components/cypress/support/commands.ts b/packages/components/cypress/support/commands.ts index 67cf98f402..a209de01d7 100644 --- a/packages/components/cypress/support/commands.ts +++ b/packages/components/cypress/support/commands.ts @@ -55,13 +55,8 @@ Cypress.Commands.add('getComponent', (component: string, story = 'default') => { cy.get(`post-${alias}`, { timeout: 30000 }).as(alias); }); -Cypress.Commands.add('checkVisibility', (visibility: 'visible' | 'hidden') => { - cy.get('@collapse').should('not.have.class', 'collapsing').and(`be.${visibility}`); -}); - -Cypress.Commands.add('checkAriaExpanded', (isExpanded: 'true' | 'false') => { - cy.get('@collapse') - .should('not.have.class', 'collapsing') +Cypress.Commands.add('checkAriaExpanded', (controlledElementSelector: string, isExpanded: 'true' | 'false') => { + cy.get(controlledElementSelector) .invoke('attr', 'id') .then(id => { cy.get(`[aria-controls="${id}"]`).should('have.attr', 'aria-expanded', isExpanded); diff --git a/packages/components/cypress/support/index.d.ts b/packages/components/cypress/support/index.d.ts index eb62b61ef7..3aa27be8e8 100644 --- a/packages/components/cypress/support/index.d.ts +++ b/packages/components/cypress/support/index.d.ts @@ -2,8 +2,7 @@ declare global { namespace Cypress { interface Chainable { getComponent(component: string, story?: string): Chainable; - checkVisibility(visibility: 'visible' | 'hidden'): Chainable; - checkAriaExpanded(isExpanded: 'true' | 'false'): Chainable; + checkAriaExpanded(controlledElementSelector: string, isExpanded: 'true' | 'false'): Chainable; } } } diff --git a/packages/components/src/animations/collapse.ts b/packages/components/src/animations/collapse.ts new file mode 100644 index 0000000000..c7d6a58979 --- /dev/null +++ b/packages/components/src/animations/collapse.ts @@ -0,0 +1,22 @@ +const collapseDuration = 350; +const collapseEasing = 'ease'; +const collapsedKeyframe: Keyframe = { height: '0', overflow: 'hidden' }; + +export const collapse = (el: HTMLElement): Animation => { + const { height } = window.getComputedStyle(el); + const expandedKeyframe: Keyframe = { height }; + + return el.animate( + [expandedKeyframe, collapsedKeyframe], + { duration: collapseDuration, easing: collapseEasing, fill: 'forwards' }, + ); +}; + +export const expand = (el: HTMLElement): Animation => { + const expandedKeyframe: Keyframe = { height: `${el.scrollHeight}px` }; + + return el.animate( + [collapsedKeyframe, expandedKeyframe], + { duration: collapseDuration, easing: collapseEasing, fill: 'forwards' }, + ); +}; diff --git a/packages/components/src/animations/fade.ts b/packages/components/src/animations/fade.ts index 1a3a94c534..b7aaa5cd20 100644 --- a/packages/components/src/animations/fade.ts +++ b/packages/components/src/animations/fade.ts @@ -1,13 +1,13 @@ const fadeDuration = 200; -const fadedOutKeyFrame = {opacity: '0'}; -const fadedInKeyFrame = {opacity: '1'}; +const fadedOutKeyframe: Keyframe = {opacity: '0'}; +const fadedInKeyframe: Keyframe = {opacity: '1'}; export const fadeIn = (el: Element): Animation => el.animate( - [ fadedOutKeyFrame, fadedInKeyFrame ], + [ fadedOutKeyframe, fadedInKeyframe ], { duration: fadeDuration } ); export const fadeOut = (el: Element): Animation => el.animate( - [ fadedInKeyFrame, fadedOutKeyFrame ], + [ fadedInKeyframe, fadedOutKeyframe ], { duration: fadeDuration } ); diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index dafbed4386..fe6f0afa76 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -6,9 +6,11 @@ */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; import { AlertType } from "./components/post-alert/alert-types"; +import { HeadingLevel } from "./components/post-collapsible/heading-levels"; import { BackgroundColor } from "./components/post-tooltip/types"; import { Placement } from "@floating-ui/dom"; export { AlertType } from "./components/post-alert/alert-types"; +export { HeadingLevel } from "./components/post-collapsible/heading-levels"; export { BackgroundColor } from "./components/post-tooltip/types"; export { Placement } from "@floating-ui/dom"; export namespace Components { @@ -46,9 +48,9 @@ export namespace Components { /** * Defines the hierarchical level of the collapsible header within the headings structure. */ - "headingLevel"?: number; + "headingLevel"?: HeadingLevel; /** - * Triggers the collapse programmatically. + * Triggers the collapse programmatically. If there is a collapsing transition running already, it will be reversed. */ "toggle": (open?: boolean) => Promise; } @@ -232,7 +234,7 @@ declare namespace LocalJSX { /** * Defines the hierarchical level of the collapsible header within the headings structure. */ - "headingLevel"?: number; + "headingLevel"?: HeadingLevel; } /** * @class PostIcon - representing a stencil component diff --git a/packages/components/src/components/post-collapsible/heading-levels.ts b/packages/components/src/components/post-collapsible/heading-levels.ts new file mode 100644 index 0000000000..0848776d55 --- /dev/null +++ b/packages/components/src/components/post-collapsible/heading-levels.ts @@ -0,0 +1,3 @@ +export const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const; + +export type HeadingLevel = typeof HEADING_LEVELS[number]; diff --git a/packages/components/src/components/post-collapsible/post-collapsible.scss b/packages/components/src/components/post-collapsible/post-collapsible.scss index 9af1fc553c..9e634b450c 100644 --- a/packages/components/src/components/post-collapsible/post-collapsible.scss +++ b/packages/components/src/components/post-collapsible/post-collapsible.scss @@ -1,5 +1,4 @@ @use '@swisspost/design-system-styles/components/accordion'; -@use '@swisspost/design-system-styles/components/transitions'; :host { display: block; diff --git a/packages/components/src/components/post-collapsible/post-collapsible.tsx b/packages/components/src/components/post-collapsible/post-collapsible.tsx index 47c0d33237..5906efea71 100644 --- a/packages/components/src/components/post-collapsible/post-collapsible.tsx +++ b/packages/components/src/components/post-collapsible/post-collapsible.tsx @@ -1,8 +1,8 @@ import { Component, Element, h, Host, Method, Prop, State, Watch } from '@stencil/core'; -import { checkOneOf, checkType, getElementHeight, onTransitionEnd } from '../../utils'; import { version } from '../../../package.json'; - -let nextId = 0; +import { collapse, expand } from '../../animations/collapse'; +import { checkEmptyOrOneOf, checkEmptyOrType, isMotionReduced } from '../../utils'; +import { HEADING_LEVELS, HeadingLevel } from './heading-levels'; @Component({ tag: 'post-collapsible', @@ -10,18 +10,15 @@ let nextId = 0; shadow: true, }) export class PostCollapsible { - private collapsibleElement: HTMLElement; private isLoaded = false; + private collapsible: HTMLElement; @Element() host: HTMLPostCollapsibleElement; - @State() collapseClasses: string; - @State() collapseHeight: string | null = null; - @State() collapsibleId: string; - @State() hasHeader: boolean; - @State() headingTag: string | undefined; + @State() id: string; @State() isOpen = true; - @State() onAccordionButtonClick = () => this.toggle(); + @State() hasHeader: boolean; + @State() headingTag: string; /** * If `true`, the element is initially collapsed otherwise it is displayed. @@ -30,128 +27,94 @@ export class PostCollapsible { @Watch('collapsed') validateCollapsed(newValue = this.collapsed) { - checkType(newValue, 'boolean', 'The post-collapsible "collapsed" prop should be a boolean.'); - - if (!this.isLoaded) { - this.isOpen = !newValue; - this.collapseClasses = this.getCollapseClasses(); - } else { - setTimeout(() => { - this.toggle(!newValue); - }); - } + checkEmptyOrType(newValue, 'boolean', 'The `collapsed` property of the `post-collapsible` must be a boolean.'); } /** * Defines the hierarchical level of the collapsible header within the headings structure. */ - @Prop() readonly headingLevel?: number = 2; + @Prop() readonly headingLevel?: HeadingLevel = 2; @Watch('headingLevel') validateHeadingLevel(newValue = this.headingLevel) { - checkOneOf( - newValue, - [1, 2, 3, 4, 5, 6], - 'The post-collapsible element requires a heading level between 1 and 6.', - ); - - this.headingTag = `h${newValue}`; + checkEmptyOrOneOf(newValue, HEADING_LEVELS, 'The `headingLevel` property of the `post-collapsible` must be a number between 1 and 6.'); } - componentWillLoad() { + connectedCallback() { this.validateCollapsed(); this.validateHeadingLevel(); + } + componentWillRender() { + this.id = this.host.id || `c${crypto.randomUUID()}`; this.hasHeader = this.host.querySelectorAll('[slot="header"]').length > 0; - if (!this.hasHeader) { - console.warn( - 'Be sure to bind the post-collapsible to its control using aria-controls and aria-expanded attributes. More information here: https://getbootstrap.com/docs/5.2/components/collapse/#accessibility', - ); - } - - this.collapsibleId = this.host.id || `post-collapsible-${nextId++}`; - this.collapseClasses = this.getCollapseClasses(); + this.headingTag = `h${this.headingLevel ?? 2}`; } componentDidLoad() { + if (this.collapsed) void this.toggle(false); this.isLoaded = true; - this.collapsibleElement = this.host.shadowRoot.querySelector( - `#${this.collapsibleId}--collapse`, - ); } /** * Triggers the collapse programmatically. + * + * If there is a collapsing transition running already, it will be reversed. */ @Method() async toggle(open = !this.isOpen): Promise { - if (open !== this.isOpen) { - this.isOpen = !this.isOpen; - - this.startTransition(); + if (open === this.isOpen) return open; - await onTransitionEnd(this.collapsibleElement).then(() => { - this.collapseHeight = null; - this.collapseClasses = this.getCollapseClasses(); - }); + this.isOpen = !this.isOpen; - return this.isOpen; - } - } + const animation = open ? expand(this.collapsible): collapse(this.collapsible); - private startTransition() { - const expandedHeight = getElementHeight(this.collapsibleElement, 'show'); + if (!this.isLoaded || isMotionReduced()) animation.finish(); - this.collapseHeight = `${this.isOpen ? 0 : expandedHeight}px`; - this.collapseClasses = 'collapsing'; + await animation.finished; - setTimeout(() => { - this.collapseHeight = `${this.isOpen ? expandedHeight : 0}px`; - }, 50); - } + animation.commitStyles(); - private getCollapseClasses() { - return this.isOpen ? 'collapse show' : 'collapse'; + return this.isOpen; } render() { - if (!this.hasHeader) { - return ( -
- -
- ); - } + const collapse = ( +
this.collapsible = el} + > + {this.hasHeader ? ( +
+ +
+ ) : ( + + )} +
+ ); return ( - -
- - - -
-
- -
+ + {this.hasHeader ? ( +
+ + + + + {collapse}
-
+ ) : collapse} ); } diff --git a/packages/components/src/components/post-collapsible/readme.md b/packages/components/src/components/post-collapsible/readme.md index e159f7a753..971a375921 100644 --- a/packages/components/src/components/post-collapsible/readme.md +++ b/packages/components/src/components/post-collapsible/readme.md @@ -7,10 +7,10 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| -------------- | --------------- | --------------------------------------------------------------------------------------- | --------- | ------- | -| `collapsed` | `collapsed` | If `true`, the element is initially collapsed otherwise it is displayed. | `boolean` | `false` | -| `headingLevel` | `heading-level` | Defines the hierarchical level of the collapsible header within the headings structure. | `number` | `2` | +| Property | Attribute | Description | Type | Default | +| -------------- | --------------- | --------------------------------------------------------------------------------------- | ---------------------------- | ------- | +| `collapsed` | `collapsed` | If `true`, the element is initially collapsed otherwise it is displayed. | `boolean` | `false` | +| `headingLevel` | `heading-level` | Defines the hierarchical level of the collapsible header within the headings structure. | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `2` | ## Methods @@ -19,6 +19,8 @@ Triggers the collapse programmatically. +If there is a collapsing transition running already, it will be reversed. + #### Returns Type: `Promise` diff --git a/packages/components/src/utils/get-element-height.ts b/packages/components/src/utils/get-element-height.ts deleted file mode 100644 index 22fbe278c5..0000000000 --- a/packages/components/src/utils/get-element-height.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function getElementHeight(el: HTMLElement): number; -export function getElementHeight(el: HTMLElement, classWhenShown: string): number; -export function getElementHeight(el: HTMLElement, classesWhenShown: string[]): number; -export function getElementHeight(el: HTMLElement, classesWhenShown: string | string[] = []): number { - if (!Array.isArray(classesWhenShown)) classesWhenShown = [classesWhenShown]; - - const classesToAdd = classesWhenShown.filter(klass => !el.classList.contains(klass)); - if (classesToAdd.length) el.classList.add(...classesToAdd); - - const scrollHeight = el.scrollHeight; - - if (classesToAdd.length) el.classList.remove(...classesToAdd); - - return scrollHeight; -} diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts index 1e7d50f4a5..8b8e9fc00e 100644 --- a/packages/components/src/utils/index.ts +++ b/packages/components/src/utils/index.ts @@ -1,4 +1,2 @@ -export * from './get-element-height'; -export * from './on-transition-end'; export * from './property-checkers'; -export * from './should-reduce-motion'; +export * from './is-motion-reduced'; diff --git a/packages/components/src/utils/should-reduce-motion.ts b/packages/components/src/utils/is-motion-reduced.ts similarity index 60% rename from packages/components/src/utils/should-reduce-motion.ts rename to packages/components/src/utils/is-motion-reduced.ts index 4b607fd03f..e9b8960930 100644 --- a/packages/components/src/utils/should-reduce-motion.ts +++ b/packages/components/src/utils/is-motion-reduced.ts @@ -1,3 +1,3 @@ -export function shouldReduceMotion(): boolean { +export function isMotionReduced(): boolean { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } diff --git a/packages/components/src/utils/on-transition-end.ts b/packages/components/src/utils/on-transition-end.ts deleted file mode 100644 index be8e192fc9..0000000000 --- a/packages/components/src/utils/on-transition-end.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { shouldReduceMotion } from './should-reduce-motion'; - -export async function onTransitionEnd(el: HTMLElement): Promise { - return new Promise(resolve => { - if (shouldReduceMotion()) { - resolve(); - } else { - el.ontransitionend = () => { - resolve(); - el.ontransitionend = null; - }; - } - }); -} diff --git a/packages/components/src/utils/tests/should-reduce-motion.spec.ts b/packages/components/src/utils/tests/is-motion-reduced.spec.ts similarity index 64% rename from packages/components/src/utils/tests/should-reduce-motion.spec.ts rename to packages/components/src/utils/tests/is-motion-reduced.spec.ts index 1e655589e0..2bb595df50 100644 --- a/packages/components/src/utils/tests/should-reduce-motion.spec.ts +++ b/packages/components/src/utils/tests/is-motion-reduced.spec.ts @@ -1,6 +1,6 @@ -import { shouldReduceMotion } from '../should-reduce-motion'; +import { isMotionReduced } from '../is-motion-reduced'; -describe('shouldReduceMotion', () => { +describe('isMotionReduced', () => { let matchMedia; beforeEach(() => { @@ -9,11 +9,11 @@ describe('shouldReduceMotion', () => { it('should return true if reduced motion is requested', () => { matchMedia.mockReturnValue({ matches: true }); - expect(shouldReduceMotion()).toBe(true); + expect(isMotionReduced()).toBe(true); }); it('should return false if reduced motion is not requested', () => { matchMedia.mockReturnValue({ matches: false }); - expect(shouldReduceMotion()).toBe(false); + expect(isMotionReduced()).toBe(false); }); }); diff --git a/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts b/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts index 39fce8c383..8f97b11c69 100644 --- a/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts +++ b/packages/documentation/src/stories/components/collapsible/collapsible.stories.ts @@ -1,10 +1,7 @@ -import { spread } from '@open-wc/lit-helpers'; -import { useArgs } from '@storybook/preview-api'; import { Meta, StoryContext, StoryObj } from '@storybook/web-components'; import { html } from 'lit'; -import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { BADGE } from '../../../../.storybook/constants'; -import { definedProperties } from '../../../utils'; +import { spreadArgs } from '../../../utils'; const meta: Meta = { title: 'Components/Collapsible', @@ -18,6 +15,7 @@ const meta: Meta = { }, args: { innerHTML: `Titulum

Contentus momentus vero siteos et accusam iretea et justo.

`, + collapsed: false, }, argTypes: { innerHTML: { @@ -46,25 +44,15 @@ function defaultRender( const hasHeader = args.innerHTML.indexOf('slot="header"') > -1; const collapsibleId = `collapsible-example--${context.name.replace(/ /g, '-').toLowerCase()}`; - const collapsibleProperties = definedProperties({ - 'collapsed': args.collapsed, - 'heading-level': args.headingLevel, - 'id': hasHeader ? undefined : collapsibleId, - }); + if (!hasHeader) args.id = collapsibleId; const collapsibleComponent = html` - - ${unsafeHTML(args.innerHTML)} - + `; - const [currentArgs, updateArgs] = useArgs(); - const toggleCollapse = (open?: boolean) => { const collapsible = document.querySelector(`#${collapsibleId}`) as HTMLPostCollapsibleElement; - collapsible.toggle(open).then((isOpen: boolean) => { - if (typeof currentArgs.collapsed !== 'undefined') updateArgs({ collapsed: !isOpen }); - }); + collapsible.toggle(open).catch(() => {/* ignore errors */}); }; const togglers = [