diff --git a/src/components/sbb-button/index.ts b/src/components/sbb-button/index.ts new file mode 100644 index 0000000000..009da7feab --- /dev/null +++ b/src/components/sbb-button/index.ts @@ -0,0 +1 @@ +export * from './sbb-button'; diff --git a/src/components/sbb-button/sbb-button.e2e.ts b/src/components/sbb-button/sbb-button.e2e.ts index d625379a9b..c229883ea3 100644 --- a/src/components/sbb-button/sbb-button.e2e.ts +++ b/src/components/sbb-button/sbb-button.e2e.ts @@ -1,97 +1,103 @@ -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; import { waitForCondition } from '../../global/testing'; +import { assert, expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import { sendKeys } from '@web/test-runner-commands'; +import { EventSpy } from '../../global/testing/event-spy'; +import { SbbButton } from './sbb-button'; describe('sbb-button', () => { - let element: E2EElement, page: E2EPage; + let element: SbbButton; beforeEach(async () => { - page = await newE2EPage(); - await page.setContent('I am a button'); - element = await page.find('sbb-button'); + element = await fixture(html`I am a button`); }); it('renders', async () => { - expect(element).toHaveClass('hydrated'); + assert.instanceOf(element, SbbButton); }); describe('events', () => { it('dispatches event on click', async () => { - await page.waitForChanges(); - const clickSpy = await page.spyOnEvent('click'); + await element.updateComplete; + const clickSpy = new EventSpy('click'); await element.click(); await waitForCondition(() => clickSpy.events.length === 1); - expect(clickSpy).toHaveReceivedEventTimes(1); + expect(clickSpy.count).to.be.equal(1); }); it('should not dispatch event on click if disabled', async () => { - element.setAttribute('disabled', true); + element.setAttribute('disabled', 'true'); - await page.waitForChanges(); + await element.updateComplete; - const clickSpy = await page.spyOnEvent('click'); + const clickSpy = new EventSpy('click'); - await element.click(); - expect(clickSpy).not.toHaveReceivedEvent(); + element.click(); + expect(clickSpy.count).not.to.be.greaterThan(0); }); it('should dispatch event on click if is-static', async () => { - element.setAttribute('is-static', true); + element.setAttribute('is-static', 'true'); - await page.waitForChanges(); + await element.updateComplete; - const clickSpy = await page.spyOnEvent('click'); + const clickSpy = new EventSpy('click'); await element.click(); - expect(clickSpy).toHaveReceivedEvent(); + expect(clickSpy.count).to.be.greaterThan(0); }); it('should dispatch click event on pressing Enter', async () => { - const clickSpy = await page.spyOnEvent('click'); - await element.press('Enter'); - expect(clickSpy).toHaveReceivedEvent(); + const clickSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: 'Enter' }); + expect(clickSpy.count).to.be.greaterThan(0); }); it('should dispatch click event on pressing Space', async () => { - const clickSpy = await page.spyOnEvent('click'); - await element.press(' '); - expect(clickSpy).toHaveReceivedEvent(); + const clickSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: ' ' }); + expect(clickSpy.count).to.be.greaterThan(0); }); it('should dispatch click event on pressing Enter with href', async () => { - element.setAttribute('href', 'test'); - await page.waitForChanges(); + element.setAttribute('href', '#'); + await element.updateComplete; - const clickSpy = await page.spyOnEvent('click'); - await element.press('Enter'); - expect(clickSpy).toHaveReceivedEvent(); + const clickSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: 'Enter' }); + expect(clickSpy.count).to.be.greaterThan(0); }); it('should not dispatch click event on pressing Space with href', async () => { - element.setAttribute('href', 'test'); - await page.waitForChanges(); + element.setAttribute('href', '#'); + await element.updateComplete; - const clickSpy = await page.spyOnEvent('click'); - await element.press(' '); - expect(clickSpy).not.toHaveReceivedEvent(); + const clickSpy = new EventSpy('click'); + element.focus(); + await sendKeys({ press: ' ' }); + expect(clickSpy.count).not.to.be.greaterThan(0); }); it('should stop propagating host click if disabled', async () => { - element.setProperty('disabled', true); + element.disabled = true; - const clickSpy = await page.spyOnEvent('click'); + const clickSpy = new EventSpy('click'); - element.triggerEvent('click'); - await page.waitForChanges(); + element.dispatchEvent(new CustomEvent('click')); + await element.updateComplete; - expect(clickSpy).not.toHaveReceivedEvent(); + expect(clickSpy.count).not.to.be.greaterThan(0); }); it('should receive focus', async () => { - await element.focus(); - await page.waitForChanges(); + element.focus(); + await element.updateComplete; - expect(await page.evaluate(() => document.activeElement.id)).toBe('focus-id'); + expect(document.activeElement.id).to.be.equal('focus-id'); }); }); }); diff --git a/src/components/sbb-button/sbb-button.spec.ts b/src/components/sbb-button/sbb-button.spec.ts index e99b8424f2..e88826c15b 100644 --- a/src/components/sbb-button/sbb-button.spec.ts +++ b/src/components/sbb-button/sbb-button.spec.ts @@ -1,184 +1,179 @@ -import { SbbButton } from './sbb-button'; -import { newSpecPage } from '@stencil/core/testing'; +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import './sbb-button'; describe('sbb-button', () => { it('renders a primary button without icon', async () => { - const { root } = await newSpecPage({ - components: [SbbButton], - html: ` - - Label Text - `, - }); - - expect(root).toEqualHtml(` - - - - - - - Label Text - - `); + const root = await fixture( + html` + Label Text + `, + ); + + expect(root).dom.to.be.equal(` + + + Label Text + + `); + expect(root).shadowDom.to.be.equal(` + + + + `); }); it('renders a primary button with slotted icon', async () => { - const { root } = await newSpecPage({ - components: [SbbButton], - html: `Label Text`, - }); - - expect(root).toEqualHtml(` - - - - - - - - - - - Label Text - - `); + const root = await fixture( + html` + + Label Text + `, + ); + + expect(root).dom.to.be.equal(` + + + Label Text + + `); + expect(root).shadowDom.to.be.equal(` + + + + + + + `); }); it('renders a button as a link', async () => { - const { root } = await newSpecPage({ - components: [SbbButton], - html: ` - - Label Text - `, - }); - - expect(root).toEqualHtml(` - + Label Text + `, + ); + + expect(root).dom.to.be.equal(` + + + Label Text + + `); + expect(root).shadowDom.to.be.equal(` + - - - - - - . Link target opens in new window. - - - - - Label Text - - `); + + + + . Link target opens in new window. + + + + `); }); it('renders a sbb-button inside an anchor as span element', async () => { - const { root } = await newSpecPage({ - components: [SbbButton], - html: `this is a button`, - }); - - expect(root).toEqualHtml(` - - - - - - - this is a button - - `); + const root = ( + await fixture( + html`this is a button`, + ) + ).querySelector('sbb-button'); + + expect(root).dom.to.be.equal(` + + this is a button + + `); + expect(root).shadowDom.to.be.equal(` + + + + `); }); it('renders a sbb-button as span element by setting is-static property', async () => { - const { root } = await newSpecPage({ - components: [SbbButton], - html: `this is a static button`, - }); - - expect(root).toEqualHtml(` - - - - - - - this is a static button - - `); + const root = await fixture( + html`this is a static button`, + ); + + expect(root).dom.to.be.equal(` + + this is a static button + + `); + expect(root).shadowDom.to.be.equal(` + + + + `); }); it('should detect icon button', async () => { - const { root } = await newSpecPage({ - components: [SbbButton], - html: ``, - }); + const root = await fixture( + html``, + ); - expect(root).toHaveAttribute('data-icon-only'); + expect(root).to.have.attribute('data-icon-only'); }); it('should detect icon button when there is space around icon', async () => { - const { root } = await newSpecPage({ - components: [SbbButton], - html: ` `, - }); + const root = await fixture( + html` `, + ); - expect(root).toHaveAttribute('data-icon-only'); + expect(root).to.have.attribute('data-icon-only'); }); - it('should render form field button variant when inside of a form field', async () => { - const { root } = await newSpecPage({ - components: [SbbButton], - html: ` - + // TODO-Migr: enable this test after the FormField migration + it.skip('should render form field button variant when inside of a form field', async () => { + const root = await fixture( + html` - + `, - }); + ); - expect(root).toHaveAttribute('data-icon-small'); + expect(root).to.have.attribute('data-icon-small'); }); }); diff --git a/src/components/sbb-button/sbb-button.stories.tsx b/src/components/sbb-button/sbb-button.stories.tsx index 905c562de8..726ab6241d 100644 --- a/src/components/sbb-button/sbb-button.stories.tsx +++ b/src/components/sbb-button/sbb-button.stories.tsx @@ -2,9 +2,10 @@ import { h, JSX } from 'jsx-dom'; import readme from './readme.md'; 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 type { InputType, StoryContext } from '@storybook/types'; import isChromatic from 'chromatic'; +import './sbb-button'; const wrapperStyle = (context: StoryContext): Record => ({ 'background-color': context.args.negative ? '#484040' : 'var(--sbb-color-white-default)', diff --git a/src/components/sbb-button/sbb-button.tsx b/src/components/sbb-button/sbb-button.tsx index 3a496ec7b0..20be0fa45b 100644 --- a/src/components/sbb-button/sbb-button.tsx +++ b/src/components/sbb-button/sbb-button.tsx @@ -1,4 +1,3 @@ -import { Component, ComponentInterface, Element, h, Host, JSX, Prop, State } from '@stencil/core'; import { InterfaceButtonAttributes } from './sbb-button.custom'; import { ButtonType, @@ -19,95 +18,100 @@ import { namedSlotChangeHandlerAspect, } from '../../global/eventing'; import { ACTION_ELEMENTS, hostContext, toggleDatasetEntry } from '../../global/dom'; +import { LitElement, nothing, TemplateResult } from 'lit'; +import { html, unsafeStatic } from 'lit/static-html.js'; +import { customElement, property, state } from 'lit/decorators.js'; +import { spread } from '@open-wc/lit-helpers'; +import { setAttribute, setAttributes } from '../../global/dom'; +import Style from './sbb-button.scss?lit&inline'; +import '../sbb-icon'; /** * @slot unnamed - Button Content * @slot icon - Slot used to display the icon, if one is set */ -@Component({ - shadow: true, - styleUrl: 'sbb-button.scss', - tag: 'sbb-button', -}) -export class SbbButton implements ComponentInterface, LinkButtonProperties, IsStaticProperty { +@customElement('sbb-button') +export class SbbButton extends LitElement implements LinkButtonProperties, IsStaticProperty { + public static override styles = Style; + /** Variant of the button, like primary, secondary etc. */ - @Prop({ reflect: true }) public variant: InterfaceButtonAttributes['variant'] = 'primary'; + @property({ reflect: true }) public variant: InterfaceButtonAttributes['variant'] = 'primary'; /** Negative coloring variant flag. */ - @Prop({ reflect: true }) public negative = false; + @property({ reflect: true, type: Boolean }) public negative = false; /** Size variant, either l or m. */ - @Prop({ reflect: true }) public size?: InterfaceButtonAttributes['size'] = 'l'; + @property({ reflect: true }) public size?: InterfaceButtonAttributes['size'] = 'l'; /** * Set this property to true if you want only a visual representation of a * button, but no interaction (a span instead of a link/button will be rendered). */ - @Prop({ mutable: true, reflect: true }) public isStatic = false; + @property({ attribute: 'is-static', reflect: true, type: Boolean }) public isStatic = false; /** * 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; /** The href value you want to link to (if it is present, button becomes a link). */ - @Prop() public href: string | undefined; + @property() public href: string | undefined; /** Where to display the linked URL. */ - @Prop() public target?: LinkTargetType | string | undefined; + @property() public target?: LinkTargetType | string | undefined; /** The relationship of the linked URL as space-separated link types. */ - @Prop() public rel?: string | undefined; + @property() public rel?: string | undefined; /** Whether the browser will show the download dialog on click. */ - @Prop() public download?: boolean; + @property({ type: Boolean }) public download?: boolean; /** The type attribute to use for the button. */ - @Prop() public type: ButtonType | undefined; + @property() public type: ButtonType | undefined; /** Whether the button is disabled. */ - @Prop({ reflect: true }) public disabled = false; + @property({ reflect: true, type: Boolean }) public disabled = false; /** The name attribute to use for the button. */ - @Prop({ reflect: true }) public name: string | undefined; + @property({ reflect: true }) public name: string | undefined; /** The value attribute to use for the button. */ - @Prop() public value?: string; + @property() public value?: string; /** The
element to associate the button with. */ - @Prop() public form?: string; - - @Element() private _element!: HTMLElement; + @property() public form?: string; /** 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'); - @State() private _hasText = false; + @state() private _hasText = false; - @State() private _currentLanguage = documentLanguage(); + @state() private _currentLanguage = documentLanguage(); private _handlerRepository = new HandlerRepository( - this._element, + this, actionElementHandlerAspect, languageChangeHandlerAspect((l) => (this._currentLanguage = l)), namedSlotChangeHandlerAspect((m) => (this._namedSlots = m(this._namedSlots))), ); - public connectedCallback(): void { + public override connectedCallback(): void { + super.connectedCallback(); // Check if the current element is nested in an action element. - this.isStatic = this.isStatic || !!hostContext(ACTION_ELEMENTS, this._element); - this._hasText = Array.from(this._element.childNodes).some( + this.isStatic = this.isStatic || !!hostContext(ACTION_ELEMENTS, this); + this._hasText = Array.from(this.childNodes).some( (n) => !(n as Element).slot && n.textContent?.trim(), ); this._handlerRepository.connect(); - if (this._element.closest('sbb-form-field') || this._element.closest('[data-form-field]')) { - toggleDatasetEntry(this._element, 'iconSmall', true); + if (this.closest('sbb-form-field') || this.closest('[data-form-field]')) { + toggleDatasetEntry(this, 'iconSmall', true); } } - public disconnectedCallback(): void { + public override disconnectedCallback(): void { + super.disconnectedCallback(); this._handlerRepository.disconnect(); } @@ -117,32 +121,48 @@ export class SbbButton implements ComponentInterface, LinkButtonProperties, IsSt .some((n) => !!n.textContent?.trim()); } - public render(): JSX.Element { + protected override render(): TemplateResult { const { tagName: TAG_NAME, attributes, hostAttributes, }: LinkButtonRenderVariables = resolveRenderVariables(this); - return ( - - - {(this.iconName || this._namedSlots.icon) && ( - - {this.iconName && } - - )} - - - this._onLabelSlotChange(event)} /> - {targetsNewWindow(this) && ( - - . {i18nTargetOpensInNewWindow[this._currentLanguage]} - - )} - - - - ); + setAttributes(this, hostAttributes); + setAttribute(this, 'data-icon-only', !this._hasText); + + /* eslint-disable lit/binding-positions */ + return html` + <${unsafeStatic(TAG_NAME)} class="sbb-button" ${spread(attributes)}> + ${ + this.iconName || this._namedSlots.icon + ? html` + + ${this.iconName ? html`` : nothing} + + ` + : nothing + } + + + this._onLabelSlotChange(event)}> + ${ + targetsNewWindow(this) + ? html` + . ${i18nTargetOpensInNewWindow[this._currentLanguage]} + ` + : nothing + } + + + `; + /* eslint-disable lit/binding-positions */ + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-button': SbbButton; } } diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 8a88a9bf06..c9435ac857 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -32,7 +32,7 @@ const browsers = process.env.CI // }), // In dev, we prefer to use puppeteer because has a better behavior in debug mode - puppeteerLauncher({ concurrency: 1, launchOptions: { headless: true, devtools: true } }), + puppeteerLauncher({ concurrency: 1, launchOptions: { headless: 'new', devtools: true } }), ]; // TODO: Revert to glob rules after migration @@ -43,7 +43,7 @@ export default { { name: 'e2e', files: e2eFiles }, ], nodeResolve: true, - reporters: [defaultReporter({ reportTestResults: false }), summaryReporter()], + reporters: [defaultReporter(), summaryReporter()], browsers: browsers, plugins: [ vitePlugin({