diff --git a/src/components/sbb-accordion/index.ts b/src/components/sbb-accordion/index.ts new file mode 100644 index 0000000000..83ac2fb3b2 --- /dev/null +++ b/src/components/sbb-accordion/index.ts @@ -0,0 +1 @@ +export * from './sbb-accordion'; diff --git a/src/components/sbb-accordion/sbb-accordion.e2e.ts b/src/components/sbb-accordion/sbb-accordion.e2e.ts index 27fb0f6ced..4604c50094 100644 --- a/src/components/sbb-accordion/sbb-accordion.e2e.ts +++ b/src/components/sbb-accordion/sbb-accordion.e2e.ts @@ -1,217 +1,211 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; -import sbbExpansionPanelEvents from '../sbb-expansion-panel/sbb-expansion-panel.events'; +import { waitForCondition, waitForLitRender } from '../../global/testing'; +import { + SbbExpansionPanel, + events as sbbExpansionPanelEvents, +} from '../sbb-expansion-panel/sbb-expansion-panel'; +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import { EventSpy } from '../../global/testing/event-spy'; +import { SbbAccordion } from './sbb-accordion'; +import { SbbExpansionPanelHeader } from '../sbb-expansion-panel-header'; +import '../sbb-expansion-panel'; +import '../sbb-expansion-panel-header'; +import '../sbb-expansion-panel-content'; describe('sbb-accordion', () => { - let element: E2EElement, page: E2EPage; + let element: SbbAccordion; beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` - - - Header 1 + element = await fixture(html` + + + Header 1 Content 1 - - Header 2 + + Header 2 Content 2 - - Header 3 + + Header 3 Content 3 `); - - element = await page.find('sbb-accordion'); - await page.waitForChanges(); }); it('renders', async () => { - expect(element).toHaveClass('hydrated'); + assert.instanceOf(element, SbbAccordion); }); it('should set accordion context on expansion panel', async () => { - const panels = await page.findAll('sbb-expansion-panel'); + const panels = Array.from(element.querySelectorAll('sbb-expansion-panel')); - expect(panels[0]).toHaveAttribute('data-accordion-first'); - expect(panels[0]).toHaveAttribute('data-accordion'); - expect(panels[1]).toHaveAttribute('data-accordion'); - expect(panels[2]).toHaveAttribute('data-accordion'); - expect(panels[2]).toHaveAttribute('data-accordion-last'); + expect(panels[0]).to.have.attribute('data-accordion-first'); + expect(panels[0]).to.have.attribute('data-accordion'); + expect(panels[1]).to.have.attribute('data-accordion'); + expect(panels[2]).to.have.attribute('data-accordion'); + expect(panels[2]).to.have.attribute('data-accordion-last'); }); it('should set accordion context on expansion panel when removing and adding expansion-panels', async () => { - let panels: E2EElement[]; - await page.waitForChanges(); + let panels: SbbExpansionPanel[]; - await page.evaluate(() => document.querySelector('sbb-expansion-panel').remove()); - await page.waitForChanges(); + element.querySelector('sbb-expansion-panel').remove(); + await waitForLitRender(element); - panels = await page.findAll('sbb-expansion-panel'); - expect(panels[0]).toHaveAttribute('data-accordion-first'); - expect(panels[1]).toHaveAttribute('data-accordion-last'); + panels = Array.from(element.querySelectorAll('sbb-expansion-panel')); + expect(panels[0]).to.have.attribute('data-accordion-first'); + expect(panels[1]).to.have.attribute('data-accordion-last'); - await page.evaluate(() => document.querySelector('sbb-expansion-panel').remove()); - await page.waitForChanges(); + element.querySelector('sbb-expansion-panel').remove(); + await waitForLitRender(element); - const lastRemainingPanel = await page.find('sbb-expansion-panel'); - expect(lastRemainingPanel).toHaveAttribute('data-accordion-first'); - expect(lastRemainingPanel).toHaveAttribute('data-accordion-last'); + const lastRemainingPanel = element.querySelector('sbb-expansion-panel'); + expect(lastRemainingPanel).to.have.attribute('data-accordion-first'); + expect(lastRemainingPanel).to.have.attribute('data-accordion-last'); - await page.evaluate(() => { - const panel = document.createElement('sbb-expansion-panel'); - document.querySelector('sbb-accordion').append(panel); - }); - await page.waitForChanges(); + const panel = document.createElement('sbb-expansion-panel'); + element.append(panel); + await waitForLitRender(element); - panels = await page.findAll('sbb-expansion-panel'); - expect(panels[0]).toHaveAttribute('data-accordion-first'); - expect(panels[0]).not.toHaveAttribute('data-accordion-last'); - expect(panels[1]).toHaveAttribute('data-accordion-last'); + panels = Array.from(element.querySelectorAll('sbb-expansion-panel')); + expect(panels[0]).to.have.attribute('data-accordion-first'); + expect(panels[0]).not.to.have.attribute('data-accordion-last'); + expect(panels[1]).to.have.attribute('data-accordion-last'); }); it('should inherit titleLevel prop by panels', async () => { - const panels = await page.findAll('sbb-expansion-panel'); - expect(panels.length).toEqual(3); - expect(panels[0].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H4', - ); - expect(panels[1].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H4', - ); - expect(panels[2].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H4', - ); + const panels = Array.from(element.querySelectorAll('sbb-expansion-panel')); + expect(panels.length).to.be.equal(3); + expect( + panels[0].shadowRoot.querySelector('.sbb-expansion-panel').firstElementChild.tagName, + ).to.be.equal('H4'); + expect( + panels[1].shadowRoot.querySelector('.sbb-expansion-panel').firstElementChild.tagName, + ).to.be.equal('H4'); + expect( + panels[2].shadowRoot.querySelector('.sbb-expansion-panel').firstElementChild.tagName, + ).to.be.equal('H4'); }); it('should dynamically update titleLevel prop', async () => { - await element.setProperty('titleLevel', '6'); - await page.waitForChanges(); - const panels = await page.findAll('sbb-expansion-panel'); - expect(panels.length).toEqual(3); - expect(panels[0].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H6', - ); - expect(panels[1].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H6', - ); - expect(panels[2].shadowRoot.querySelector('.sbb-expansion-panel').firstChild.nodeName).toEqual( - 'H6', - ); + element.titleLevel = '6'; + await waitForLitRender(element); + const panels = Array.from(element.querySelectorAll('sbb-expansion-panel')); + expect(panels.length).to.be.equal(3); + expect( + panels[0].shadowRoot.querySelector('.sbb-expansion-panel').firstElementChild.tagName, + ).to.be.equal('H6'); + expect( + panels[1].shadowRoot.querySelector('.sbb-expansion-panel').firstElementChild.tagName, + ).to.be.equal('H6'); + expect( + panels[2].shadowRoot.querySelector('.sbb-expansion-panel').firstElementChild.tagName, + ).to.be.equal('H6'); }); it('should close others when expanding and multi = false', async () => { - const willOpenEventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.willOpen); - const panelOne: E2EElement = await page.find('#panel-1'); - const headerOne: E2EElement = await page.find('#header-1'); - const panelTwo: E2EElement = await page.find('#panel-2'); - const headerTwo: E2EElement = await page.find('#header-2'); - const panelThree: E2EElement = await page.find('#panel-3'); - const headerThree: E2EElement = await page.find('#header-3'); + const willOpenEventSpy = new EventSpy(sbbExpansionPanelEvents.willOpen); + const panelOne: SbbExpansionPanel = element.querySelector('#panel-1'); + const headerOne: SbbExpansionPanelHeader = element.querySelector('#header-1'); + const panelTwo: SbbExpansionPanel = element.querySelector('#panel-2'); + const headerTwo: SbbExpansionPanelHeader = element.querySelector('#header-2'); + const panelThree: SbbExpansionPanel = element.querySelector('#panel-3'); + const headerThree: SbbExpansionPanelHeader = element.querySelector('#header-3'); for (const panel of [panelOne, panelTwo, panelThree]) { - expect(await panel.getProperty('expanded')).toEqual(false); + expect(panel.expanded).to.be.equal(false); } - await headerTwo.click(); - await page.waitForChanges(); + headerTwo.click(); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(false); - expect(await panelTwo.getProperty('expanded')).toEqual(true); - expect(await panelThree.getProperty('expanded')).toEqual(false); - - await headerOne.click(); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); + expect(panelOne.expanded).to.be.equal(false); + expect(panelTwo.expanded).to.be.equal(true); + expect(panelThree.expanded).to.be.equal(false); + + headerOne.click(); await waitForCondition(() => willOpenEventSpy.events.length === 2); - expect(willOpenEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(true); - expect(await panelTwo.getProperty('expanded')).toEqual(false); - expect(await panelThree.getProperty('expanded')).toEqual(false); - - await headerThree.click(); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(2); + expect(panelOne.expanded).to.be.equal(true); + expect(panelTwo.expanded).to.be.equal(false); + expect(panelThree.expanded).to.be.equal(false); + + headerThree.click(); await waitForCondition(() => willOpenEventSpy.events.length === 3); - expect(willOpenEventSpy).toHaveReceivedEventTimes(3); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(false); - expect(await panelTwo.getProperty('expanded')).toEqual(false); - expect(await panelThree.getProperty('expanded')).toEqual(true); + expect(willOpenEventSpy.count).to.be.equal(3); + expect(panelOne.expanded).to.be.equal(false); + expect(panelTwo.expanded).to.be.equal(false); + expect(panelThree.expanded).to.be.equal(true); }); it('should not change others when expanding and multi = false', async () => { - await element.setProperty('multi', 'true'); - await page.waitForChanges(); - const willOpenEventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.willOpen); - const panelOne: E2EElement = await page.find('#panel-1'); - const panelTwo: E2EElement = await page.find('#panel-2'); - const panelThree: E2EElement = await page.find('#panel-3'); + element.multi = true; + await waitForLitRender(element); + const willOpenEventSpy = new EventSpy(sbbExpansionPanelEvents.willOpen); + const panelOne: SbbExpansionPanel = element.querySelector('#panel-1'); + const headerOne: SbbExpansionPanelHeader = element.querySelector('#header-1'); + const panelTwo: SbbExpansionPanel = element.querySelector('#panel-2'); + const headerTwo: SbbExpansionPanelHeader = element.querySelector('#header-2'); + const panelThree: SbbExpansionPanel = element.querySelector('#panel-3'); + const headerThree: SbbExpansionPanelHeader = element.querySelector('#header-3'); + for (const panel of [panelOne, panelTwo, panelThree]) { - expect(await panel.getProperty('expanded')).toEqual(false); + expect(panel.expanded).to.be.equal(false); } - await panelTwo.click(); - await page.waitForChanges(); + headerTwo.click(); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(false); - expect(await panelTwo.getProperty('expanded')).toEqual(true); - expect(await panelThree.getProperty('expanded')).toEqual(false); - - await panelOne.click(); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); + expect(panelOne.expanded).to.be.equal(false); + expect(panelTwo.expanded).to.be.equal(true); + expect(panelThree.expanded).to.be.equal(false); + + headerOne.click(); await waitForCondition(() => willOpenEventSpy.events.length === 2); - expect(willOpenEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(true); - expect(await panelTwo.getProperty('expanded')).toEqual(true); - expect(await panelThree.getProperty('expanded')).toEqual(false); - - await panelThree.click(); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(2); + expect(panelOne.expanded).to.be.equal(true); + expect(panelTwo.expanded).to.be.equal(true); + expect(panelThree.expanded).to.be.equal(false); + + headerThree.click(); await waitForCondition(() => willOpenEventSpy.events.length === 3); - expect(willOpenEventSpy).toHaveReceivedEventTimes(3); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(true); - expect(await panelTwo.getProperty('expanded')).toEqual(true); - expect(await panelThree.getProperty('expanded')).toEqual(true); + expect(willOpenEventSpy.count).to.be.equal(3); + expect(panelOne.expanded).to.be.equal(true); + expect(panelTwo.expanded).to.be.equal(true); + expect(panelThree.expanded).to.be.equal(true); }); it('should close all panels except the first when multi changes from true to false', async () => { - await element.setProperty('multi', 'true'); - await page.waitForChanges(); - const panelOne: E2EElement = await page.find('#panel-1'); - const panelTwo: E2EElement = await page.find('#panel-2'); - const panelThree: E2EElement = await page.find('#panel-3'); + element.multi = true; + await waitForLitRender(element); + const panelOne: SbbExpansionPanel = element.querySelector('#panel-1'); + const panelTwo: SbbExpansionPanel = element.querySelector('#panel-2'); + const headerTwo: SbbExpansionPanelHeader = element.querySelector('#header-2'); + const panelThree: SbbExpansionPanel = element.querySelector('#panel-3'); + const headerThree: SbbExpansionPanelHeader = element.querySelector('#header-3'); + for (const panel of [panelOne, panelTwo, panelThree]) { - expect(await panel.getProperty('expanded')).toEqual(false); + expect(panel.expanded).to.be.equal(false); } - const willOpenEventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.willOpen); + const willOpenEventSpy = new EventSpy(sbbExpansionPanelEvents.willOpen); - await panelTwo.click(); - await page.waitForChanges(); + headerTwo.click(); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - expect(await panelTwo.getProperty('expanded')).toEqual(true); + expect(willOpenEventSpy.count).to.be.equal(1); + expect(panelTwo.expanded).to.be.equal(true); - await panelThree.click(); - await page.waitForChanges(); + headerThree.click(); await waitForCondition(() => willOpenEventSpy.events.length === 2); - expect(willOpenEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - expect(await panelThree.getProperty('expanded')).toEqual(true); - - await element.setProperty('multi', 'false'); - await page.waitForChanges(); - expect(await panelOne.getProperty('expanded')).toEqual(true); - expect(await panelTwo.getProperty('expanded')).toEqual(false); - expect(await panelThree.getProperty('expanded')).toEqual(false); + expect(willOpenEventSpy.count).to.be.equal(2); + expect(panelThree.expanded).to.be.equal(true); + + element.multi = false; + await waitForLitRender(element); + expect(panelOne.expanded).to.be.equal(true); + expect(panelTwo.expanded).to.be.equal(false); + expect(panelThree.expanded).to.be.equal(false); }); }); diff --git a/src/components/sbb-accordion/sbb-accordion.spec.ts b/src/components/sbb-accordion/sbb-accordion.spec.ts index c5106f537c..f9c275b4fc 100644 --- a/src/components/sbb-accordion/sbb-accordion.spec.ts +++ b/src/components/sbb-accordion/sbb-accordion.spec.ts @@ -1,40 +1,72 @@ -import { SbbAccordion } from './sbb-accordion'; -import { newSpecPage } from '@stencil/core/testing'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './sbb-accordion'; +import '../sbb-expansion-panel'; +import '../sbb-expansion-panel-header'; +import '../sbb-expansion-panel-content'; describe('sbb-accordion', () => { it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbAccordion], - html: ` - - - Header 1 - Content 1 - - - Header 2 - Content 2 - - - `, - }); + const root = await fixture(html` + + + Header 1 + Content 1 + + + Header 2 + Content 2 + + + `); - expect(root).toEqualHtml(` + expect(root).dom.to.be.equal( + ` - -
- -
-
- - Header 1 - Content 1 + + + - - Header 2 - Content 2 + + +
- `); + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
+ +
+ `, + ); }); }); diff --git a/src/components/sbb-accordion/sbb-accordion.stories.tsx b/src/components/sbb-accordion/sbb-accordion.stories.tsx index a2be2cf79f..d59283b98b 100644 --- a/src/components/sbb-accordion/sbb-accordion.stories.tsx +++ b/src/components/sbb-accordion/sbb-accordion.stories.tsx @@ -1,11 +1,16 @@ /** @jsx h */ import { h, JSX } from 'jsx-dom'; import readme from './readme.md?raw'; -import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/html'; +import type { Meta, StoryObj, ArgTypes, Args } from '@storybook/web-components'; import { InputType, StoryContext } from '@storybook/types'; -import sbbExpansionPanelEvents from '../sbb-expansion-panel/sbb-expansion-panel.events'; +import { events as sbbExpansionPanelEvents } from '../sbb-expansion-panel/sbb-expansion-panel'; import { withActions } from '@storybook/addon-actions/decorator'; -import { Decorator } from '@storybook/html'; +import { Decorator } from '@storybook/web-components'; +import './sbb-accordion'; +import '../sbb-expansion-panel'; +import '../sbb-expansion-panel-header'; +import '../sbb-expansion-panel-content'; +import '../sbb-icon'; const numberOfPanels: InputType = { control: { diff --git a/src/components/sbb-accordion/sbb-accordion.tsx b/src/components/sbb-accordion/sbb-accordion.tsx index d8432aa34b..09aecf19a5 100644 --- a/src/components/sbb-accordion/sbb-accordion.tsx +++ b/src/components/sbb-accordion/sbb-accordion.tsx @@ -1,27 +1,51 @@ -import { Component, ComponentInterface, Element, h, JSX, Listen, Prop, Watch } from '@stencil/core'; -import { InterfaceTitleAttributes } from '../sbb-title/sbb-title.custom'; import { toggleDatasetEntry } from '../../global/dom'; +import { CSSResult, html, LitElement, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { ConnectedAbortController } from '../../global/eventing'; +import { SbbExpansionPanel } from '../sbb-expansion-panel'; +import { TitleLevel } from '../sbb-title'; +import Style from './sbb-accordion.scss?lit&inline'; /** * @slot unnamed - Use this to add one or more sbb-expansion-panel. */ -@Component({ - shadow: true, - styleUrl: 'sbb-accordion.scss', - tag: 'sbb-accordion', -}) -export class SbbAccordion implements ComponentInterface { +@customElement('sbb-accordion') +export class SbbAccordion extends LitElement { + public static override styles: CSSResult = Style; + /** The heading level for the sbb-expansion-panel-headers within the component. */ - @Prop() public titleLevel?: InterfaceTitleAttributes['level']; + @property({ attribute: 'title-level' }) + public get titleLevel(): TitleLevel | null { + return this._titleLevel; + } + public set titleLevel(value: TitleLevel | null) { + const oldValue = this._titleLevel; + this._titleLevel = value; + this._setTitleLevelOnChildren(); + this.requestUpdate('titleLevel', oldValue); + } + private _titleLevel: TitleLevel | null = null; /** Whether the animation should be disabled. */ - @Prop({ reflect: true }) public disableAnimation = false; + @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) + public disableAnimation = false; /** Whether more than one sbb-expansion-panel can be open at the same time. */ - @Prop() public multi = false; + @property({ type: Boolean }) + public get multi(): boolean { + return this._multi; + } + public set multi(value: boolean) { + const oldValue = this._multi; + this._multi = value; + this._resetExpansionPanels(this._multi, oldValue); + this.requestUpdate('multi', oldValue); + } + private _multi: boolean = false; + + private _abort = new ConnectedAbortController(this); - @Listen('will-open') - public closePanels(e): void { + private _closePanels(e): void { if (e.target?.tagName !== 'SBB-EXPANSION-PANEL' || this.multi) { return; } @@ -31,8 +55,7 @@ export class SbbAccordion implements ComponentInterface { .forEach((panel) => (panel.expanded = false)); } - @Watch('multi') - public resetExpansionPanels(newValue: boolean, oldValue: boolean): void { + private _resetExpansionPanels(newValue: boolean, oldValue: boolean): void { // If it's changing from "multi = true" to "multi = false", open the first panel and close all the others. const expansionPanels = this._expansionPanels; if (expansionPanels.length > 1 && oldValue && !newValue) { @@ -43,15 +66,12 @@ export class SbbAccordion implements ComponentInterface { } } - @Watch('titleLevel') - public setTitleLevelOnChildren(): void { + private _setTitleLevelOnChildren(): void { this._expansionPanels.forEach((panel) => (panel.titleLevel = this.titleLevel)); } - @Element() private _element!: HTMLElement; - - private get _expansionPanels(): HTMLSbbExpansionPanelElement[] { - return Array.from(this._element.querySelectorAll('sbb-expansion-panel')); + private get _expansionPanels(): SbbExpansionPanel[] { + return Array.from(this.querySelectorAll('sbb-expansion-panel')); } private _setChildrenParameters(): void { @@ -60,7 +80,7 @@ export class SbbAccordion implements ComponentInterface { return; } - expansionPanels.forEach((panel: HTMLSbbExpansionPanelElement) => { + expansionPanels.forEach((panel: SbbExpansionPanel) => { panel.titleLevel = this.titleLevel; toggleDatasetEntry(panel, 'accordionFirst', false); @@ -76,11 +96,24 @@ export class SbbAccordion implements ComponentInterface { toggleDatasetEntry(expansionPanels[expansionPanels.length - 1], 'accordionLast', true); } - public render(): JSX.Element { - return ( + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + this.addEventListener('will-open', (e) => this._closePanels(e), { signal }); + } + + protected override render(): TemplateResult { + return html`
- this._setChildrenParameters()}> + this._setChildrenParameters()}>
- ); + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-accordion': SbbAccordion; } } diff --git a/src/components/sbb-expansion-panel-content/index.ts b/src/components/sbb-expansion-panel-content/index.ts new file mode 100644 index 0000000000..0c2ceaab17 --- /dev/null +++ b/src/components/sbb-expansion-panel-content/index.ts @@ -0,0 +1 @@ +export * from './sbb-expansion-panel-content'; diff --git a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.e2e.ts b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.e2e.ts index 00bfab6416..77c23e5ffa 100644 --- a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.e2e.ts +++ b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.e2e.ts @@ -1,13 +1,14 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; +import { assert, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import { SbbExpansionPanelContent } from './sbb-expansion-panel-content'; describe('sbb-expansion-panel-content', () => { - let element: E2EElement, page: E2EPage; + let element: SbbExpansionPanelContent; it('renders', async () => { - page = await newE2EPage(); - await page.setContent('Content'); - - element = await page.find('sbb-expansion-panel-content'); - expect(element).toHaveClass('hydrated'); + element = await fixture( + html`Content`, + ); + assert.instanceOf(element, SbbExpansionPanelContent); }); }); diff --git a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.spec.ts b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.spec.ts index 7208d2e7f1..562cc74243 100644 --- a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.spec.ts +++ b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.spec.ts @@ -1,40 +1,47 @@ -import { SbbExpansionPanelContent } from './sbb-expansion-panel-content'; -import { newSpecPage } from '@stencil/core/testing'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './sbb-expansion-panel-content'; describe('sbb-expansion-panel-content', () => { it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanelContent], - html: 'Content', - }); + const root = await fixture( + html`Content`, + ); - expect(root).toEqualHtml(` - - -
- -
-
- Content -
- `); + expect(root).dom.to.be.equal( + ` + + Content + + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
+ +
+ `, + ); }); it('renders expanded', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanelContent], - html: 'Content', - }); + const root = await fixture( + html`Content`, + ); - expect(root).toEqualHtml(` - - -
- -
-
- Content -
- `); + expect(root).dom.to.be.equal( + ` + + Content + + `, + ); + expect(root).shadowDom.to.be.equal( + ` +
+ +
+ `, + ); }); }); diff --git a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.stories.tsx b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.stories.tsx index 0065166e15..d7c3c108ee 100644 --- a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.stories.tsx +++ b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.stories.tsx @@ -1,7 +1,8 @@ /** @jsx h */ import { h, JSX } from 'jsx-dom'; import readme from './readme.md?raw'; -import type { Meta, StoryObj } from '@storybook/html'; +import type { Meta, StoryObj } from '@storybook/web-components'; +import '../sbb-card'; const Template = (): JSX.Element => ( diff --git a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.tsx b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.tsx index 0c6674e5b8..4638311ea0 100644 --- a/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.tsx +++ b/src/components/sbb-expansion-panel-content/sbb-expansion-panel-content.tsx @@ -1,21 +1,30 @@ -import { Component, ComponentInterface, h, Host, JSX } from '@stencil/core'; +import { CSSResult, html, LitElement, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { setAttribute } from '../../global/dom'; +import Style from './sbb-expansion-panel-content.scss?lit&inline'; /** * @slot unnamed - Slot to render the content in the sbb-expansion-panel. */ -@Component({ - shadow: true, - styleUrl: 'sbb-expansion-panel-content.scss', - tag: 'sbb-expansion-panel-content', -}) -export class SbbExpansionPanelContent implements ComponentInterface { - public render(): JSX.Element { - return ( - -
- -
-
- ); +@customElement('sbb-expansion-panel-content') +export class SbbExpansionPanelContent extends LitElement { + public static override styles: CSSResult = Style; + + protected override render(): TemplateResult { + setAttribute(this, 'slot', 'content'); + setAttribute(this, 'role', 'region'); + + return html` +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-expansion-panel-content': SbbExpansionPanelContent; } } diff --git a/src/components/sbb-expansion-panel-header/index.ts b/src/components/sbb-expansion-panel-header/index.ts new file mode 100644 index 0000000000..5b6395ca92 --- /dev/null +++ b/src/components/sbb-expansion-panel-header/index.ts @@ -0,0 +1 @@ +export * from './sbb-expansion-panel-header'; diff --git a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.e2e.ts b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.e2e.ts index 872fbb8a41..abc0d876e6 100644 --- a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.e2e.ts +++ b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.e2e.ts @@ -1,32 +1,31 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import { EventSpy } from '../../global/testing/event-spy'; +import { SbbExpansionPanelHeader } from './sbb-expansion-panel-header'; describe('sbb-expansion-panel-header', () => { - let element: E2EElement, page: E2EPage; + let element: SbbExpansionPanelHeader; beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(`Header`); - element = await page.find('sbb-expansion-panel-header'); + element = await fixture(html`Header`); }); it('renders', async () => { - expect(element).toHaveClass('hydrated'); + assert.instanceOf(element, SbbExpansionPanelHeader); }); it('should emit event on click', async () => { - const spy = await page.spyOnEvent('toggle-expanded'); - await element.click(); - expect(spy).toHaveReceivedEvent(); + const spy = new EventSpy('toggle-expanded'); + element.click(); + expect(spy.count).to.be.greaterThan(0); }); it('should not emit event on click if disabled', async () => { - page = await newE2EPage(); - await page.setContent( - `Header`, + element = await fixture( + html`Header`, ); - element = await page.find('sbb-expansion-panel-header'); - const spy = await page.spyOnEvent('toggle-expanded'); - await element.click(); - expect(spy).not.toHaveReceivedEvent(); + const spy = new EventSpy('toggle-expanded'); + element.click(); + expect(spy.count).not.to.be.greaterThan(0); }); }); diff --git a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.events.ts b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.events.ts deleted file mode 100644 index aeb450e7e3..0000000000 --- a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.events.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - toggleExpanded: 'toggle-expanded', -}; diff --git a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.spec.ts b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.spec.ts index 3a5d7e4b25..de85ca429d 100644 --- a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.spec.ts +++ b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.spec.ts @@ -1,88 +1,129 @@ -import { SbbExpansionPanelHeader } from './sbb-expansion-panel-header'; -import { newSpecPage } from '@stencil/core/testing'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './sbb-expansion-panel-header'; describe('sbb-expansion-panel-header', () => { it('renders collapsed', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanelHeader], - html: 'Header', - }); + const root = await fixture( + html`Header`, + ); - expect(root).toEqualHtml(` - - - - - - - - - + expect(root).dom.to.be.equal( + ` + + Header + + `, + ); + expect(root).shadowDom.to.be.equal( + ` + + + - - Header - - `); + + + + + `, + ); }); it('renders with icon', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanelHeader], - html: 'Header', - }); + const root = await fixture( + html`Header`, + ); - expect(root).toEqualHtml(` - - - - - - - - - - - - - + expect(root).dom.to.be.equal( + ` + + Header + + `, + ); + expect(root).shadowDom.to.be.equal( + ` + + + + + + + - + + + + + `, + ); + }); + + it('renders with slotted icon', async () => { + const root = await fixture(html` + + Header `); - }); - it('renders with slotted icon', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanelHeader], - html: ` - - + expect(root).dom.to.be.equal( + ` + + Header `, - }); - - expect(root).toEqualHtml(` - - - - - - - - - - - - + ); + expect(root).shadowDom.to.be.equal( + ` + + + + + + - - - Header - - `); + + + + + `, + ); }); }); diff --git a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.stories.tsx b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.stories.tsx index 485cb595ab..2736c1bdf1 100644 --- a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.stories.tsx +++ b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.stories.tsx @@ -1,7 +1,8 @@ /** @jsx h */ import { h, JSX } from 'jsx-dom'; import readme from './readme.md?raw'; -import type { Meta, StoryObj } from '@storybook/html'; +import type { Meta, StoryObj } from '@storybook/web-components'; +import '../sbb-card'; const Template = (): JSX.Element => ( diff --git a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.tsx b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.tsx index 9dfe313323..8438981ca2 100644 --- a/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.tsx +++ b/src/components/sbb-expansion-panel-header/sbb-expansion-panel-header.tsx @@ -1,114 +1,119 @@ -import { - Component, - ComponentInterface, - Element, - Event, - EventEmitter, - h, - Host, - JSX, - Prop, - State, -} from '@stencil/core'; import { actionElementHandlerAspect, createNamedSlotState, HandlerRepository, namedSlotChangeHandlerAspect, + EventEmitter, + ConnectedAbortController, } from '../../global/eventing'; -import { ButtonProperties, resolveButtonRenderVariables } from '../../global/interfaces'; -import { toggleDatasetEntry } from '../../global/dom'; +import { resolveButtonRenderVariables } from '../../global/interfaces'; +import { setAttribute, setAttributes, toggleDatasetEntry } from '../../global/dom'; +import { CSSResult, html, LitElement, nothing, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { SbbExpansionPanel } from '../sbb-expansion-panel'; +import Style from './sbb-expansion-panel-header.scss?lit&inline'; +import '../sbb-icon'; /** * @slot icon - Slot used to render the panel header icon. * @slot unnamed - Slot used to render the panel header text. */ -@Component({ - shadow: true, - styleUrl: 'sbb-expansion-panel-header.scss', - tag: 'sbb-expansion-panel-header', -}) -export class SbbExpansionPanelHeader implements ButtonProperties, ComponentInterface { +export const events = { + toggleExpanded: 'toggle-expanded', +}; + +@customElement('sbb-expansion-panel-header') +export class SbbExpansionPanelHeader extends LitElement { + public static override styles: CSSResult = Style; + /** * The icon name we want to use, choose from the small icon variants * from the ui-icons category from here * https://icons.app.sbb.ch. */ - @Prop() public iconName?: string; + @property({ attribute: 'icon-name' }) public iconName?: string; /** Whether the button is disabled. */ - @Prop({ reflect: true }) public disabled: boolean; - - @Element() private _element!: HTMLElement; + @property({ reflect: true, type: Boolean }) public disabled: boolean; /** State of listed named slots, by indicating whether any element for a named slot is defined. */ - @State() private _namedSlots = createNamedSlotState('icon'); + @state() private _namedSlots = createNamedSlotState('icon'); - @Event({ + private _toggleExpanded: EventEmitter = new EventEmitter(this, events.toggleExpanded, { bubbles: true, - eventName: 'toggle-expanded', - }) - public toggleExpanded: EventEmitter; + }); + private _abort = new ConnectedAbortController(this); private _handlerRepository = new HandlerRepository( - this._element, + this, actionElementHandlerAspect, namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), ); - public connectedCallback(): void { + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; this._handlerRepository.connect(); + this.addEventListener('click', () => this._emitExpandedEvent(), { signal }); + this.addEventListener('mouseenter', () => this._onMouseMovement(true), { signal }); + this.addEventListener('mouseleave', () => this._onMouseMovement(false), { signal }); } - public disconnectedCallback(): void { + public override disconnectedCallback(): void { + super.disconnectedCallback(); this._handlerRepository.disconnect(); } private _emitExpandedEvent(): void { if (!this.disabled) { - this.toggleExpanded.emit(); + this._toggleExpanded.emit(); } } private _onMouseMovement(toggleDataAttribute: boolean): void { - const parent: HTMLSbbExpansionPanelElement = this._element.closest('sbb-expansion-panel'); + const parent: SbbExpansionPanel = this.closest('sbb-expansion-panel'); // The `sbb.hover-mq` logic has been removed from scss, but it must be replicated to have the correct behavior on mobile. if (!toggleDataAttribute || (parent && window.matchMedia('(any-hover: hover)').matches)) { toggleDatasetEntry(parent, 'toggleHover', toggleDataAttribute); } } - public render(): JSX.Element { + protected override render(): TemplateResult { const { hostAttributes } = resolveButtonRenderVariables(this); - return ( - this._emitExpandedEvent()} - onMouseenter={() => this._onMouseMovement(true)} - onMouseleave={() => this._onMouseMovement(false)} - > - - - {(this.iconName || this._namedSlots.icon) && ( - - {this.iconName && } - - )} - - - {!this.disabled && ( - + setAttributes(this, hostAttributes); + setAttribute(this, 'slot', 'header'); + setAttribute(this, 'data-icon', !!(this.iconName || this._namedSlots.icon)); + + return html` + + + ${this.iconName || this._namedSlots.icon + ? html` + ${this.iconName ? html`` : nothing} + + ` + : nothing} + + + ${!this.disabled + ? html` - - )} - - - ); + > + + ` + : nothing} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-expansion-panel-header': SbbExpansionPanelHeader; } } diff --git a/src/components/sbb-expansion-panel/index.ts b/src/components/sbb-expansion-panel/index.ts new file mode 100644 index 0000000000..d564711471 --- /dev/null +++ b/src/components/sbb-expansion-panel/index.ts @@ -0,0 +1 @@ +export * from './sbb-expansion-panel'; diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.e2e.ts b/src/components/sbb-expansion-panel/sbb-expansion-panel.e2e.ts index b09b50d91c..a2b47359b5 100644 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.e2e.ts +++ b/src/components/sbb-expansion-panel/sbb-expansion-panel.e2e.ts @@ -1,106 +1,110 @@ -import { E2EElement, E2EPage, EventSpy, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; -import sbbExpansionPanelHeaderEvents from '../sbb-expansion-panel-header/sbb-expansion-panel-header.events'; -import sbbExpansionPanelEvents from './sbb-expansion-panel.events'; +import { waitForCondition, waitForLitRender } from '../../global/testing'; +import { + SbbExpansionPanelHeader, + events as sbbExpansionPanelHeaderEvents, +} from '../sbb-expansion-panel-header/sbb-expansion-panel-header'; +import { events as sbbExpansionPanelEvents } from './sbb-expansion-panel'; +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import { EventSpy } from '../../global/testing/event-spy'; +import { SbbExpansionPanel } from './sbb-expansion-panel'; +import { SbbExpansionPanelContent } from '../sbb-expansion-panel-content'; +import '../sbb-expansion-panel-header'; +import '../sbb-expansion-panel-content'; describe('sbb-expansion-panel', () => { - let element: E2EElement, page: E2EPage; + let element: SbbExpansionPanel; beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` + element = await fixture(html` - Header + Header Content `); - - element = await page.find('sbb-expansion-panel'); }); it('renders', async () => { - expect(element).toHaveClass('hydrated'); + assert.instanceOf(element, SbbExpansionPanel); }); it('has slotted elements with the correct properties', async () => { - const header = await page.find('sbb-expansion-panel-header'); - expect(header).toEqualAttribute('id', 'sbb-expansion-panel-header-1'); - expect(header).toEqualAttribute('aria-controls', 'sbb-expansion-panel-content-1'); - expect(header).toEqualAttribute('data-icon', ''); - const content = await page.find('sbb-expansion-panel-content'); - expect(content).toEqualAttribute('id', 'sbb-expansion-panel-content-1'); - expect(content).toEqualAttribute('aria-labelledby', `sbb-expansion-panel-header-1`); - expect(content).toEqualAttribute('data-icon-space', ''); + const header = element.querySelector('sbb-expansion-panel-header'); + expect(header).to.have.attribute('id', 'sbb-expansion-panel-header-2'); + expect(header).to.have.attribute('aria-controls', 'sbb-expansion-panel-content-2'); + expect(header).to.have.attribute('data-icon'); + + const content = element.querySelector('sbb-expansion-panel-content'); + expect(content).to.have.attribute('id', 'sbb-expansion-panel-content-2'); + expect(content).to.have.attribute('aria-labelledby', `sbb-expansion-panel-header-2`); + expect(content).to.have.attribute('data-icon-space'); }); it('has slotted elements with the correct properties when id are set', async () => { - page = await newE2EPage(); - await page.setContent(` + element = await fixture(html` - Header - Content + Header + Content `); - const header = await page.find('sbb-expansion-panel-header'); - expect(header).toEqualAttribute('aria-controls', 'content'); - const content = await page.find('sbb-expansion-panel-content'); - expect(content).toEqualAttribute('aria-labelledby', `header`); + const header = element.querySelector('sbb-expansion-panel-header'); + expect(header).to.have.attribute('aria-controls', 'content'); + const content = element.querySelector('sbb-expansion-panel-content'); + expect(content).to.have.attribute('aria-labelledby', `header`); }); it('click the header expands the panel, click again collapses it', async () => { - const header: E2EElement = await page.find('sbb-expansion-panel-header'); - const content: E2EElement = await page.find('sbb-expansion-panel-content'); - expect(await element.getProperty('expanded')).toEqual(false); - expect(header.getAttribute('aria-expanded')).toEqual('false'); - expect(content.getAttribute('aria-hidden')).toEqual('true'); + const header: SbbExpansionPanelHeader = element.querySelector('sbb-expansion-panel-header'); + const content: SbbExpansionPanelContent = element.querySelector('sbb-expansion-panel-content'); + expect(element.expanded).to.be.equal(false); + expect(header.getAttribute('aria-expanded')).to.be.equal('false'); + expect(content.getAttribute('aria-hidden')).to.be.equal('true'); - const toggleExpandedEventSpy: EventSpy = await page.spyOnEvent( - sbbExpansionPanelHeaderEvents.toggleExpanded, - ); - const willOpenEventSpy: EventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.willOpen); - const willCloseEventSpy: EventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.willClose); - const didOpenEventSpy: EventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.didOpen); - const didCloseEventSpy: EventSpy = await page.spyOnEvent(sbbExpansionPanelEvents.didClose); + const toggleExpandedEventSpy = new EventSpy(sbbExpansionPanelHeaderEvents.toggleExpanded); + const willOpenEventSpy = new EventSpy(sbbExpansionPanelEvents.willOpen); + const willCloseEventSpy = new EventSpy(sbbExpansionPanelEvents.willClose); + const didOpenEventSpy = new EventSpy(sbbExpansionPanelEvents.didOpen); + const didCloseEventSpy = new EventSpy(sbbExpansionPanelEvents.didClose); - await header.click(); + header.click(); await waitForCondition(() => toggleExpandedEventSpy.events.length === 1); - expect(toggleExpandedEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - expect(await element.getProperty('expanded')).toEqual(true); - expect(header.getAttribute('aria-expanded')).toEqual('true'); - expect(content.getAttribute('aria-hidden')).toEqual('false'); + expect(toggleExpandedEventSpy.count).to.be.equal(1); + await waitForLitRender(element); + expect(element.expanded).to.be.equal(true); + expect(header.getAttribute('aria-expanded')).to.be.equal('true'); + expect(content.getAttribute('aria-hidden')).to.be.equal('false'); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); + expect(willOpenEventSpy.count).to.be.equal(1); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); + expect(didOpenEventSpy.count).to.be.equal(1); - await header.click(); + header.click(); await waitForCondition(() => toggleExpandedEventSpy.events.length === 2); - expect(toggleExpandedEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - expect(await element.getProperty('expanded')).toEqual(false); - expect(header.getAttribute('aria-expanded')).toEqual('false'); - expect(content.getAttribute('aria-hidden')).toEqual('true'); + expect(toggleExpandedEventSpy.count).to.be.equal(2); + await waitForLitRender(element); + expect(element.expanded).to.be.equal(false); + expect(header.getAttribute('aria-expanded')).to.be.equal('false'); + expect(content.getAttribute('aria-hidden')).to.be.equal('true'); await waitForCondition(() => willCloseEventSpy.events.length === 1); - expect(willCloseEventSpy).toHaveReceivedEventTimes(1); + expect(willCloseEventSpy.count).to.be.equal(1); await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); + expect(didCloseEventSpy.count).to.be.equal(1); }); it('disabled property is proxied to header', async () => { - const header: E2EElement = await page.find('sbb-expansion-panel-header'); - expect(await header.getProperty('disabled')).toBeUndefined(); - expect(header).not.toHaveAttribute('aria-disabled'); + const header: SbbExpansionPanelHeader = element.querySelector('sbb-expansion-panel-header'); + expect(header.disabled).to.be.undefined; + expect(header).not.to.have.attribute('aria-disabled'); - element.setProperty('disabled', true); - await page.waitForChanges(); - expect(await header.getProperty('disabled')).toEqual(true); - expect(header).toEqualAttribute('aria-disabled', 'true'); + element.disabled = true; + await waitForLitRender(element); + expect(header.disabled).to.be.equal(true); + expect(header).to.have.attribute('aria-disabled', 'true'); - element.setProperty('disabled', false); - await page.waitForChanges(); - expect(await header.getProperty('disabled')).toEqual(false); - expect(header).toEqualAttribute('aria-disabled', null); + element.disabled = false; + await waitForLitRender(element); + expect(header.disabled).to.be.equal(false); + expect(header).not.to.have.attribute('aria-disabled'); }); }); diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.events.ts b/src/components/sbb-expansion-panel/sbb-expansion-panel.events.ts deleted file mode 100644 index cf7d67d8ae..0000000000 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.events.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * This file is autogenerated by the event-sync plugin. - * See stencil.config.ts in the root directory. - */ -export default { - didClose: 'did-close', - didOpen: 'did-open', - willClose: 'will-close', - willOpen: 'will-open', -}; diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.spec.ts b/src/components/sbb-expansion-panel/sbb-expansion-panel.spec.ts index 2b468f8762..1a317aa8dd 100644 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.spec.ts +++ b/src/components/sbb-expansion-panel/sbb-expansion-panel.spec.ts @@ -1,66 +1,69 @@ -import { SbbExpansionPanel } from './sbb-expansion-panel'; -import { newSpecPage } from '@stencil/core/testing'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './sbb-expansion-panel'; describe('sbb-expansion-panel', () => { it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanel], - html: ` + const root = await fixture(html` + + Header + Content + + `); + + expect(root).dom.to.be.equal( + ` Header Content `, - }); - - expect(root).toEqualHtml(` - - -
-
- -
-
- - - -
+ ); + expect(root).shadowDom.to.be.equal( + ` +
+
+ +
+
+ + +
- +
+ `, + ); + }); + + it('renders with level set', async () => { + const root = await fixture(html` + Header Content `); - }); - it('renders with level set', async () => { - const { root } = await newSpecPage({ - components: [SbbExpansionPanel], - html: ` + expect(root).dom.to.be.equal( + ` Header Content `, - }); - - expect(root).toEqualHtml(` - - -
-

- -

-
- - - -
+ ); + expect(root).shadowDom.to.be.equal( + ` +
+

+ +

+
+ + +
- - Header - Content - - `); +
+ `, + ); }); }); diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.stories.tsx b/src/components/sbb-expansion-panel/sbb-expansion-panel.stories.tsx index c773d2d673..35bbd29be1 100644 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.stories.tsx +++ b/src/components/sbb-expansion-panel/sbb-expansion-panel.stories.tsx @@ -1,11 +1,15 @@ /** @jsx h */ -import events from './sbb-expansion-panel.events'; -import panelHeaderEvents from '../sbb-expansion-panel-header/sbb-expansion-panel-header.events'; +import { events } from './sbb-expansion-panel'; +import { events as panelHeaderEvents } from '../sbb-expansion-panel-header/sbb-expansion-panel-header'; import { h, JSX } from 'jsx-dom'; import readme from './readme.md?raw'; import { withActions } from '@storybook/addon-actions/decorator'; -import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/html'; +import type { Meta, StoryObj, ArgTypes, Args, Decorator } from '@storybook/web-components'; import { InputType, StoryContext } from '@storybook/types'; +import './sbb-expansion-panel'; +import '../sbb-expansion-panel-header'; +import '../sbb-expansion-panel-content'; +import '../sbb-icon'; const longText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer enim elit, ultricies in tincidunt quis, mattis eu quam. Nulla sit amet lorem fermentum, molestie nunc ut, hendrerit risus. Vestibulum rutrum elit et diff --git a/src/components/sbb-expansion-panel/sbb-expansion-panel.tsx b/src/components/sbb-expansion-panel/sbb-expansion-panel.tsx index b66ac849c1..5c5a2372b5 100644 --- a/src/components/sbb-expansion-panel/sbb-expansion-panel.tsx +++ b/src/components/sbb-expansion-panel/sbb-expansion-panel.tsx @@ -1,18 +1,14 @@ -import { - Component, - ComponentInterface, - Element, - Event, - EventEmitter, - h, - JSX, - Listen, - Prop, - Watch, -} from '@stencil/core'; -import { InterfaceTitleAttributes } from '../sbb-title/sbb-title.custom'; import { toggleDatasetEntry } from '../../global/dom'; import { InterfaceSbbExpansionPanelAttributes } from './sbb-expansion-panel.custom'; +import { CSSResult, LitElement, TemplateResult } from 'lit'; +import { html, unsafeStatic } from 'lit/static-html.js'; +import { customElement, property } from 'lit/decorators.js'; +import { EventEmitter, ConnectedAbortController } from '../../global/eventing'; +import { SbbExpansionPanelHeader } from '../sbb-expansion-panel-header'; +import { SbbExpansionPanelContent } from '../sbb-expansion-panel-content'; +import { TitleLevel } from '../sbb-title'; +import { SbbOverlayState } from '../../global/overlay'; +import Style from './sbb-expansion-panel.scss?lit&inline'; let nextId = 0; @@ -20,82 +16,89 @@ let nextId = 0; * @slot header - Use this to render the sbb-expansion-panel-header. * @slot content - Use this to render the sbb-expansion-panel-content. */ -@Component({ - shadow: true, - styleUrl: 'sbb-expansion-panel.scss', - tag: 'sbb-expansion-panel', -}) -export class SbbExpansionPanel implements ComponentInterface { +export const events = { + willOpen: 'will-open', + didOpen: 'did-open', + willClose: 'will-close', + didClose: 'did-close', +}; + +@customElement('sbb-expansion-panel') +export class SbbExpansionPanel extends LitElement { + public static override styles: CSSResult = Style; + /** Heading level; if unset, a `div` will be rendered. */ - @Prop() public titleLevel?: InterfaceTitleAttributes['level']; + @property({ attribute: 'title-level' }) public titleLevel?: TitleLevel; /** The background color of the panel. */ - @Prop() public color: InterfaceSbbExpansionPanelAttributes['color'] = 'white'; + @property() public color: InterfaceSbbExpansionPanelAttributes['color'] = 'white'; /** Whether the panel is expanded. */ - @Prop({ mutable: true, reflect: true }) public expanded = false; + @property({ reflect: true, type: Boolean }) + public get expanded(): boolean { + return this._expanded; + } + public set expanded(value: boolean) { + const oldValue = this._expanded; + this._expanded = value; + this._onExpandedChange(); + this.requestUpdate('expanded', oldValue); + } + private _expanded: boolean = false; /** Whether the panel is disabled, so its expanded state can't be changed. */ - @Prop({ reflect: true }) public disabled = false; + @property({ reflect: true, type: Boolean }) + public get disabled(): boolean { + return this._disabled; + } + public set disabled(value: boolean) { + const oldValue = this._disabled; + this._disabled = value; + this._updateDisabledOnHeader(this._disabled); + this.requestUpdate('disabled', oldValue); + } + private _disabled: boolean = false; /** Whether the panel has no border. */ - @Prop({ reflect: true }) public borderless = false; + @property({ reflect: true, type: Boolean }) public borderless = false; /** Whether the animations should be disabled. */ - @Prop({ reflect: true }) public disableAnimation = false; + @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) + public disableAnimation = false; /** Emits whenever the sbb-expansion-panel starts the opening transition. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'will-open', - }) - public willOpen: EventEmitter; + private _willOpen: EventEmitter = new EventEmitter(this, events.willOpen); /** Emits whenever the sbb-expansion-panel is opened. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'did-open', - }) - public didOpen: EventEmitter; + private _didOpen: EventEmitter = new EventEmitter(this, events.didOpen); /** Emits whenever the sbb-expansion-panel begins the closing transition. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'will-close', - }) - public willClose: EventEmitter; + private _willClose: EventEmitter = new EventEmitter(this, events.willClose); /** Emits whenever the sbb-expansion-panel is closed. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'did-close', - }) - public didClose: EventEmitter; + private _didClose: EventEmitter = new EventEmitter(this, events.didClose); - @Element() private _element!: HTMLSbbExpansionPanelElement; + private _abort = new ConnectedAbortController(this); + private _state: SbbOverlayState = 'closed'; - @Listen('toggle-expanded') - public toggleExpanded(): void { + private _toggleExpanded(): void { this.expanded = !this.expanded; } - @Watch('expanded') - public onExpandedChange(): void { + private _onExpandedChange(): void { this._headerRef.setAttribute('aria-expanded', String(this.expanded)); this._contentRef.setAttribute('aria-hidden', String(!this.expanded)); if (this.expanded) { - this.willOpen.emit(); + this._willOpen.emit(); + this._state = 'opening'; // As with 0s duration, transitionEnd will not be fired, we need to programmatically trigger didOpen event if (this.disableAnimation) { this._onOpened(); } - } else { - this.willClose.emit(); + } else if (this._state === 'opened') { + this._willClose.emit(); + this._state = 'closing'; // As with 0s duration, transitionEnd will not be fired, we need to programmatically trigger didClose event if (this.disableAnimation) { this._onClosed(); @@ -103,32 +106,38 @@ export class SbbExpansionPanel implements ComponentInterface { } } - @Watch('disabled') - public updateDisabledOnHeader(newDisabledValue: boolean): void { + private _updateDisabledOnHeader(newDisabledValue: boolean): void { this._headerRef.disabled = newDisabledValue; } private _transitionEventController: AbortController; private _progressiveId = `-${++nextId}`; - private _headerRef: HTMLSbbExpansionPanelHeaderElement; - private _contentRef: HTMLSbbExpansionPanelContentElement; - - public connectedCallback(): void { - const accordion = this._element.closest('sbb-accordion'); - toggleDatasetEntry(this._element, 'accordion', !!accordion); + private _headerRef: SbbExpansionPanelHeader; + private _contentRef: SbbExpansionPanelContent; + private _contentWrapperRef: HTMLElement; + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + this.addEventListener('toggle-expanded', () => this._toggleExpanded(), { signal }); + const accordion = this.closest('sbb-accordion'); + toggleDatasetEntry(this, 'accordion', !!accordion); } - public disconnectedCallback(): void { + public override disconnectedCallback(): void { + super.disconnectedCallback(); this._transitionEventController?.abort(); - toggleDatasetEntry(this._element, 'accordion', false); + toggleDatasetEntry(this, 'accordion', false); } private _onOpened(): void { - this.didOpen.emit(); + this._didOpen.emit(); + this._state = 'opened'; } private _onClosed(): void { - this.didClose.emit(); + this._didClose.emit(); + this._state = 'closed'; } private _onHeaderSlotChange(event): void { @@ -140,7 +149,7 @@ export class SbbExpansionPanel implements ComponentInterface { } this._headerRef = elements.find( - (e): e is HTMLSbbExpansionPanelHeaderElement => e.tagName === 'SBB-EXPANSION-PANEL-HEADER', + (e): e is SbbExpansionPanelHeader => e.tagName === 'SBB-EXPANSION-PANEL-HEADER', ); if (!this._headerRef) { @@ -154,7 +163,7 @@ export class SbbExpansionPanel implements ComponentInterface { this._linkHeaderAndContent(); } - private _onContentSlotChange(event): void { + private _onContentSlotChange(event: Event): void { const elements = (event.target as HTMLSlotElement).assignedElements(); if (!elements.length) { @@ -165,20 +174,25 @@ export class SbbExpansionPanel implements ComponentInterface { this._contentRef = (event.target as HTMLSlotElement) .assignedElements() - .find( - (e): e is HTMLSbbExpansionPanelContentElement => - e.tagName === 'SBB-EXPANSION-PANEL-CONTENT', - ); + .find((e): e is SbbExpansionPanelContent => e.tagName === 'SBB-EXPANSION-PANEL-CONTENT'); - if (!this._contentRef) { + this._contentWrapperRef = this.shadowRoot.querySelector( + '.sbb-expansion-panel__content-wrapper', + ); + + if (!this._contentRef || !this._contentWrapperRef) { return; } this._transitionEventController = new AbortController(); this._contentRef.setAttribute('aria-hidden', String(!this.expanded)); - this._contentRef.addEventListener('transitionend', (event) => this._onTransitionEnd(event), { - signal: this._transitionEventController.signal, - }); + this._contentWrapperRef.addEventListener( + 'transitionend', + (event) => this._onTransitionEnd(event), + { + signal: this._transitionEventController.signal, + }, + ); this._linkHeaderAndContent(); } @@ -205,7 +219,7 @@ export class SbbExpansionPanel implements ComponentInterface { toggleDatasetEntry(this._contentRef, 'iconSpace', this._headerRef.hasAttribute('data-icon')); } - private _onTransitionEnd(event): void { + private _onTransitionEnd(event: TransitionEvent): void { // All transitions have the same timing and opacity is defined last, be sure that they have all been performed. if (event.propertyName !== 'opacity') { return; @@ -218,20 +232,31 @@ export class SbbExpansionPanel implements ComponentInterface { } } - public render(): JSX.Element { + protected override render(): TemplateResult { const TAGNAME = this.titleLevel ? `h${this.titleLevel}` : 'div'; - return ( + /* eslint-disable lit/binding-positions */ + return html`
- - this._onHeaderSlotChange(event)}> - -
+ <${unsafeStatic(TAGNAME)} class="sbb-expansion-panel__header"> + + this._onHeaderSlotChange(event)}> + +
- this._onContentSlotChange(event)}> + + this._onContentSlotChange(event)}>
- ); + `; + /* eslint-disable lit/binding-positions */ + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-expansion-panel': SbbExpansionPanel; } } diff --git a/src/components/sbb-navigation/sbb-navigation.tsx b/src/components/sbb-navigation/sbb-navigation.tsx index 0faebea187..bf3eaad9a9 100644 --- a/src/components/sbb-navigation/sbb-navigation.tsx +++ b/src/components/sbb-navigation/sbb-navigation.tsx @@ -331,8 +331,8 @@ export class SbbNavigation extends LitElement { // Validate trigger element and attach event listeners this._configure(this.trigger); this._navigationObserver.observe(this, navigationObserverConfig); - this.addEventListener('pointerup', (event) => this._closeOnBackdropClick(event)); - this.addEventListener('pointerdown', (event) => this._pointerDownListener(event)); + this.addEventListener('pointerup', (event) => this._closeOnBackdropClick(event), { signal }); + this.addEventListener('pointerdown', (event) => this._pointerDownListener(event), { signal }); } public override disconnectedCallback(): void {