diff --git a/src/components/sbb-navigation/sbb-navigation.e2e.ts b/src/components/sbb-navigation/sbb-navigation.e2e.ts index f22f0604273..051bd6e7419 100644 --- a/src/components/sbb-navigation/sbb-navigation.e2e.ts +++ b/src/components/sbb-navigation/sbb-navigation.e2e.ts @@ -227,7 +227,6 @@ describe('sbb-navigation', () => { expect(secondSectionDialog).not.to.have.attribute('open'); secondAction.click(); - console.log('second click'); await waitForCondition(() => secondSection.getAttribute('data-state') === 'opened'); expect(firstSection.getAttribute('data-state')).not.to.be.equal('opened'); diff --git a/src/components/sbb-tooltip-trigger/index.ts b/src/components/sbb-tooltip-trigger/index.ts new file mode 100644 index 00000000000..cc34a31a32e --- /dev/null +++ b/src/components/sbb-tooltip-trigger/index.ts @@ -0,0 +1 @@ +export * from './sbb-tooltip-trigger'; diff --git a/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.e2e.ts b/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.e2e.ts index c49468510a4..d3fb33cd9f4 100644 --- a/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.e2e.ts +++ b/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.e2e.ts @@ -1,112 +1,122 @@ import events from '../sbb-tooltip/sbb-tooltip.events'; -import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; + +import { assert, expect, fixture } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; +import { waitForCondition, waitForLitRender } from '../../global/testing'; +import { EventSpy } from '../../global/testing/event-spy'; +import '../sbb-tooltip/sbb-tooltip'; +import { SbbTooltipTrigger } from './sbb-tooltip-trigger'; describe('sbb-tooltip-trigger', () => { - let element: E2EElement, page: E2EPage; + let element: SbbTooltipTrigger; beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` + await fixture(html` - Tooltip content. Link + Tooltip content. + Link `); - element = await page.find('sbb-tooltip-trigger'); - await page.waitForChanges(); + element = document.querySelector('sbb-tooltip-trigger'); + await element.updateComplete; }); it('renders', () => { - expect(element).toHaveClass('hydrated'); + assert.instanceOf(element, SbbTooltipTrigger); }); it('shows tooltip on tooltip-trigger click', async () => { - const dialog = await page.find('sbb-tooltip >>> dialog'); - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); + // NOTE: the ">>>" operator is not supported outside stencil. (convert it to something like "element.shadowRoot.querySelector(...)") + const dialog = document.querySelector('sbb-tooltip').shadowRoot.querySelector('dialog'); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); - await page.waitForChanges(); + await element.updateComplete; await element.click(); - await page.waitForChanges(); + await element.updateComplete; await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); + expect(willOpenEventSpy.count).to.be.equal(1); - await page.waitForChanges(); + await element.updateComplete; await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); + expect(didOpenEventSpy.count).to.be.equal(1); - await page.waitForChanges(); - expect(dialog).toHaveAttribute('open'); + await element.updateComplete; + expect(dialog).to.have.attribute('open'); }); it("doesn't show tooltip on disabled tooltip-trigger click", async () => { - const dialog = await page.find('sbb-tooltip >>> dialog'); - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - element.setProperty('disabled', true); + // NOTE: the ">>>" operator is not supported outside stencil. (convert it to something like "element.shadowRoot.querySelector(...)") + const dialog = document.querySelector('sbb-tooltip').shadowRoot.querySelector('dialog'); + const willOpenEventSpy = new EventSpy(events.willOpen); + element.disabled = true; - await page.waitForChanges(); + await element.updateComplete; await element.click(); - await page.waitForChanges(); + await element.updateComplete; await waitForCondition(() => willOpenEventSpy.events.length === 0); - expect(willOpenEventSpy).toHaveReceivedEventTimes(0); + expect(willOpenEventSpy.count).to.be.equal(0); - await page.waitForChanges(); - expect(dialog).not.toHaveAttribute('open'); + await element.updateComplete; + expect(dialog).not.to.have.attribute('open'); }); it('shows tooltip on keyboard event', async () => { - const tooltipTrigger = await page.find('sbb-tooltip-trigger'); - const dialog = await page.find('sbb-tooltip >>> dialog'); - const changeSpy = await tooltipTrigger.spyOnEvent('focus'); + const tooltipTrigger = document.querySelector('sbb-tooltip-trigger'); + // NOTE: the ">>>" operator is not supported outside stencil. (convert it to something like "element.shadowRoot.querySelector(...)") + const dialog = document.querySelector('sbb-tooltip').shadowRoot.querySelector('dialog'); + const changeSpy = new EventSpy('focus', tooltipTrigger); await tooltipTrigger.focus(); - await page.waitForChanges(); + await element.updateComplete; await waitForCondition(() => changeSpy.events.length === 1); - expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy.count).to.be.equal(1); - await page.keyboard.down('Enter'); - await page.waitForChanges(); + await sendKeys({ down: 'Enter' }); + await element.updateComplete; - expect(dialog).toHaveAttribute('open'); + expect(dialog).to.have.attribute('open'); }); it('shows tooltip on keyboard event with hover-trigger', async () => { - const tooltipTrigger = await page.find('sbb-tooltip-trigger'); - const tooltip = await page.find('sbb-tooltip'); - const dialog = await page.find('sbb-tooltip >>> dialog'); - const changeSpy = await tooltipTrigger.spyOnEvent('focus'); + const tooltipTrigger = document.querySelector('sbb-tooltip-trigger'); + const tooltip = document.querySelector('sbb-tooltip'); + // NOTE: the ">>>" operator is not supported outside stencil. (convert it to something like "element.shadowRoot.querySelector(...)") + const dialog = document.querySelector('sbb-tooltip').shadowRoot.querySelector('dialog'); + const changeSpy = new EventSpy('focus', tooltipTrigger); - tooltip.setProperty('hoverTrigger', true); - await page.waitForChanges(); + tooltip.hoverTrigger = true; + await element.updateComplete; await tooltipTrigger.focus(); - await page.waitForChanges(); + await element.updateComplete; await waitForCondition(() => changeSpy.events.length === 1); - expect(changeSpy).toHaveReceivedEventTimes(1); + expect(changeSpy.count).to.be.equal(1); - await page.keyboard.down('Enter'); - await page.waitForChanges(); + await sendKeys({ down: 'Enter' }); + await element.updateComplete; - expect(dialog).toHaveAttribute('open'); + expect(dialog).to.have.attribute('open'); }); it("doesn't focus tooltip-trigger on keyboard event when disabled", async () => { - const tooltipTrigger = await page.find('sbb-tooltip-trigger'); - const tooltip = await page.find('sbb-tooltip'); - const dialog = await page.find('sbb-tooltip >>> dialog'); - const changeSpy = await tooltipTrigger.spyOnEvent('focus'); + const tooltipTrigger = document.querySelector('sbb-tooltip-trigger'); + const tooltip = document.querySelector('sbb-tooltip'); + const dialog = document.querySelector('sbb-tooltip').shadowRoot.querySelector('dialog'); + const changeSpy = new EventSpy('focus', tooltipTrigger); - element.setProperty('disabled', true); - tooltip.setProperty('hoverTrigger', true); - await page.waitForChanges(); + tooltipTrigger.disabled = true; + tooltip.hoverTrigger = true; + await waitForLitRender(element); - await tooltipTrigger.focus(); - await page.waitForChanges(); + await sendKeys({ down: 'Tab' }); + await waitForLitRender(element); - expect(changeSpy).not.toHaveReceivedEvent(); - expect(dialog).not.toHaveAttribute('open'); + expect(changeSpy.count).not.to.be.greaterThan(0); + expect(dialog).not.to.have.attribute('open'); }); }); diff --git a/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.scss b/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.scss index 416a2add7b6..2fe47e6b8c8 100644 --- a/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.scss +++ b/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.scss @@ -35,7 +35,7 @@ --sbb-tooltip-color: var(--sbb-color-cement-default); } -:host([disabled]:not([disabled='false'])) { +:host([disabled]) { --sbb-tooltip-color: var(--sbb-color-graphite-default); @include sbb.if-forced-colors { @@ -45,7 +45,7 @@ pointer-events: none; } -:host([disabled]:not([disabled='false'])[negative]:not([negative='false'])) { +:host([disabled][negative]:not([negative='false'])) { --sbb-tooltip-color: var(--sbb-color-smoke-default); } @@ -55,7 +55,7 @@ @include sbb.icon-button-variables-negative; } -:host([data-icon-small][disabled]:not([disabled='false'])) { +:host([data-icon-small][disabled]) { @include sbb.icon-button-disabled('.sbb-tooltip-trigger'); } @@ -65,7 +65,7 @@ @include sbb.icon-button-focus-visible('.sbb-tooltip-trigger'); } -:host([data-icon-small]:not([disabled]:not([disabled='false']), :active, [data-active]):hover) { +:host([data-icon-small]:not([disabled], :active, [data-active]):hover) { @include sbb.icon-button-hover('.sbb-tooltip-trigger'); } diff --git a/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.spec.ts b/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.spec.ts index 3cbf9030a70..d008211943a 100644 --- a/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.spec.ts +++ b/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.spec.ts @@ -1,43 +1,50 @@ -import { SbbTooltipTrigger } from './sbb-tooltip-trigger'; -import { newSpecPage } from '@stencil/core/testing'; +import '../sbb-icon'; +import './sbb-tooltip-trigger'; + +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; describe('sbb-tooltip-trigger', () => { it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbTooltipTrigger], - html: '', - }); + const root = await fixture(html``); - expect(root).toEqualHtml(` - - - - - - - - - - `); + expect(root).dom.to.be.equal( + ``, + ); + expect(root).shadowDom.to.be.equal( + ` + + + + + `, + ); }); it('renders with custom content', async () => { - const { root } = await newSpecPage({ - components: [SbbTooltipTrigger], - html: 'Custom Content', - }); + const root = await fixture(html`Custom Content`); - expect(root).toEqualHtml(` - - - - - - - - - Custom Content - - `); + expect(root).dom.to.be.equal( + ` + Custom Content + `, + ); + expect(root).shadowDom.to.be.equal( + ` + + + + `, + ); }); }); diff --git a/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.stories.tsx b/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.stories.tsx index e147e526385..34cddc9d881 100644 --- a/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.stories.tsx +++ b/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.stories.tsx @@ -1,8 +1,11 @@ /** @jsx h */ import { Fragment, h, JSX } from 'jsx-dom'; import readme from './readme.md?raw'; -import type { Meta, StoryObj, ArgTypes, Args, StoryContext } from '@storybook/html'; +import type { Meta, StoryObj, ArgTypes, Args, StoryContext } from '@storybook/web-components'; import type { InputType } from '@storybook/types'; +import './sbb-tooltip-trigger'; +import '../sbb-tooltip'; +import '../sbb-link'; const wrapperStyle = (context: StoryContext): Record => ({ 'background-color': context.args.negative diff --git a/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.tsx b/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.tsx index d6772a7a433..2565d368bde 100644 --- a/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.tsx +++ b/src/components/sbb-tooltip-trigger/sbb-tooltip-trigger.tsx @@ -1,62 +1,68 @@ -import { Component, ComponentInterface, Element, h, Host, JSX, Prop } from '@stencil/core'; -import { ButtonProperties, resolveButtonRenderVariables } from '../../global/interfaces'; -import { hostContext, isValidAttribute, toggleDatasetEntry } from '../../global/dom'; +import { CSSResult, LitElement, TemplateResult, html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { hostContext, isValidAttribute, setAttributes, toggleDatasetEntry } from '../../global/dom'; import { HandlerRepository, actionElementHandlerAspect } from '../../global/eventing'; +import { resolveButtonRenderVariables } from '../../global/interfaces'; +import '../sbb-icon'; +import Style from './sbb-tooltip-trigger.scss?lit&inline'; /** * @slot unnamed - Slot to render the content. */ -@Component({ - shadow: true, - styleUrl: 'sbb-tooltip-trigger.scss', - tag: 'sbb-tooltip-trigger', -}) -export class SbbTooltipTrigger implements ComponentInterface, ButtonProperties { +@customElement('sbb-tooltip-trigger') +export class SbbTooltipTrigger extends LitElement { + public static override styles: CSSResult = Style; + /** The name attribute to use for the button. */ - @Prop({ reflect: true }) public name: string | undefined; + @property({ reflect: true }) public name: string | undefined; /** Negative coloring variant flag. */ - @Prop({ reflect: true, mutable: true }) public negative = false; + @property({ reflect: true, type: Boolean }) public negative = 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 = 'circle-information-small'; + @property({ attribute: 'icon-name' }) public iconName = 'circle-information-small'; /** Whether the tooltip-trigger is disabled. */ - @Prop({ reflect: true }) public disabled = false; - - @Element() private _element!: HTMLElement; + @property({ reflect: true, type: Boolean }) public disabled; - private _handlerRepository = new HandlerRepository(this._element, actionElementHandlerAspect); + private _handlerRepository = new HandlerRepository(this, actionElementHandlerAspect); - public connectedCallback(): void { + public override connectedCallback(): void { + super.connectedCallback(); this._handlerRepository.connect(); - const formField = - hostContext('sbb-form-field', this._element) ?? - hostContext('[data-form-field]', this._element); + const formField = hostContext('sbb-form-field', this) ?? hostContext('[data-form-field]', this); if (formField) { - toggleDatasetEntry(this._element, 'iconSmall', true); + toggleDatasetEntry(this, 'iconSmall', true); this.negative = isValidAttribute(formField as HTMLElement, 'negative'); } } - public disconnectedCallback(): void { + public override disconnectedCallback(): void { + super.disconnectedCallback(); this._handlerRepository.disconnect(); } - public render(): JSX.Element { + protected override render(): TemplateResult { const { hostAttributes } = resolveButtonRenderVariables(this); - return ( - - - {this.iconName && } - - - ); + setAttributes(this, hostAttributes); + + return html` + + ${this.iconName ? html`` : nothing} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-tooltip-trigger': SbbTooltipTrigger; } } diff --git a/src/components/sbb-tooltip/index.ts b/src/components/sbb-tooltip/index.ts new file mode 100644 index 00000000000..9d0993343f2 --- /dev/null +++ b/src/components/sbb-tooltip/index.ts @@ -0,0 +1 @@ +export * from './sbb-tooltip'; diff --git a/src/components/sbb-tooltip/sbb-tooltip.e2e.ts b/src/components/sbb-tooltip/sbb-tooltip.e2e.ts index c67d44efbd9..94a084506f2 100644 --- a/src/components/sbb-tooltip/sbb-tooltip.e2e.ts +++ b/src/components/sbb-tooltip/sbb-tooltip.e2e.ts @@ -1,421 +1,398 @@ import events from './sbb-tooltip.events'; -import { E2EPage, newE2EPage, E2EElement } from '@stencil/core/testing'; -import { waitForCondition } from '../../global/testing'; + +import { assert, expect, fixture, fixtureCleanup } from '@open-wc/testing'; +import { sendKeys, sendMouse, setViewport } from '@web/test-runner-commands'; +import { html } from 'lit/static-html.js'; +import { waitForCondition, waitForLitRender } from '../../global/testing'; +import { EventSpy } from '../../global/testing/event-spy'; +import '../sbb-button'; +import { SbbButton } from '../sbb-button'; +import '../sbb-link'; +import './sbb-tooltip'; +import { SbbTooltip } from './sbb-tooltip'; describe('sbb-tooltip', () => { - let element: E2EElement, trigger: E2EElement, page: E2EPage; + let element: SbbTooltip, trigger: SbbButton; beforeEach(async () => { - page = await newE2EPage(); - await page.setContent(` + await fixture(html` Tooltip trigger - Tooltip content. Link + Tooltip content. + Link - Other interactive element + Other interactive element `); - trigger = await page.find('sbb-button'); - element = await page.find('sbb-tooltip'); + trigger = document.querySelector('sbb-button'); + element = document.querySelector('sbb-tooltip'); }); it('renders', () => { - expect(element).toHaveClass('hydrated'); + assert.instanceOf(element, SbbTooltip); }); it('shows the tooltip', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const dialog = await page.find('sbb-tooltip >>> dialog'); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); + // NOTE: the ">>>" operator is not supported outside stencil. (convert it to something like "element.shadowRoot.querySelector(...)") + const dialog = element.shadowRoot.querySelector('dialog'); - await element.callMethod('open'); - await page.waitForChanges(); + element.open(); + await waitForLitRender(element); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); - expect(dialog).toHaveAttribute('open'); + expect(dialog).to.have.attribute('open'); }); it('shows on trigger click', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const dialog = await page.find('sbb-tooltip >>> dialog'); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); + // NOTE: the ">>>" operator is not supported outside stencil. (convert it to something like "element.shadowRoot.querySelector(...)") + const dialog = element.shadowRoot.querySelector('dialog'); - trigger.triggerEvent('click'); - await page.waitForChanges(); + trigger.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); - expect(dialog).toHaveAttribute('open'); + expect(dialog).to.have.attribute('open'); }); it('closes the tooltip', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const willCloseEventSpy = await page.spyOnEvent(events.willClose); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); - const dialog = await page.find('sbb-tooltip >>> dialog'); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); + const willCloseEventSpy = new EventSpy(events.willClose); + const didCloseEventSpy = new EventSpy(events.didClose); + // NOTE: the ">>>" operator is not supported outside stencil. (convert it to something like "element.shadowRoot.querySelector(...)") + const dialog = element.shadowRoot.querySelector('dialog'); - await element.callMethod('open'); - await page.waitForChanges(); + element.open(); + await waitForLitRender(element); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didOpenEventSpy.count).to.be.equal(1); + await waitForLitRender(element); - expect(dialog).toHaveAttribute('open'); + expect(dialog).to.have.attribute('open'); - await element.callMethod('close'); - await page.waitForChanges(); + element.close(); + await waitForLitRender(element); await waitForCondition(() => willCloseEventSpy.events.length === 1); - expect(willCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); - expect(dialog).not.toHaveAttribute('open'); + expect(dialog).not.to.have.attribute('open'); }); it('closes the tooltip on close button click', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const willCloseEventSpy = await page.spyOnEvent(events.willClose); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); - const dialog = await page.find('sbb-tooltip >>> dialog'); - const closeButton = await page.find('sbb-tooltip >>> [sbb-tooltip-close]'); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); + const willCloseEventSpy = new EventSpy(events.willClose); + const didCloseEventSpy = new EventSpy(events.didClose); + const dialog = element.shadowRoot.querySelector('dialog'); + const closeButton = element.shadowRoot.querySelector('[sbb-tooltip-close]') as HTMLElement; - await element.callMethod('open'); - await page.waitForChanges(); + element.open(); + await waitForLitRender(element); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didOpenEventSpy.count).to.be.equal(1); - expect(dialog).toHaveAttribute('open'); + expect(dialog).to.have.attribute('open'); - await closeButton.click(); - await page.waitForChanges(); + closeButton.click(); + await waitForLitRender(element); await waitForCondition(() => willCloseEventSpy.events.length === 1); - expect(willCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willCloseEventSpy.count).to.be.equal(1); await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didCloseEventSpy.count).to.be.equal(1); - expect(dialog).not.toHaveAttribute('open'); - expect(trigger).toEqualAttribute('data-focus-origin', 'mouse'); - expect(await page.evaluate(() => document.activeElement.id)).toBe('tooltip-trigger'); + expect(dialog).not.to.have.attribute('open'); + expect(trigger).to.have.attribute('data-focus-origin', 'mouse'); + expect(document.activeElement.id).to.be.equal('tooltip-trigger'); }); it('closes the tooltip on close button click by keyboard', async () => { - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); - const closeButton = await page.find('sbb-tooltip >>> [sbb-tooltip-close]'); + const didOpenEventSpy = new EventSpy(events.didOpen); + const didCloseEventSpy = new EventSpy(events.didClose); + const closeButton = document + .querySelector('sbb-tooltip') + .shadowRoot.querySelector('[sbb-tooltip-close]') as HTMLElement; - await element.callMethod('open'); - await page.waitForChanges(); + element.open(); + await waitForLitRender(element); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didOpenEventSpy.count).to.be.equal(1); await closeButton.focus(); - await page.keyboard.down('Enter'); - await page.waitForChanges(); + await sendKeys({ down: 'Enter' }); + await waitForLitRender(element); await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didCloseEventSpy.count).to.be.equal(1); - expect(trigger).toEqualAttribute('data-focus-origin', 'keyboard'); - expect(await page.evaluate(() => document.activeElement.id)).toBe('tooltip-trigger'); + expect(trigger).to.have.attribute('data-focus-origin', 'keyboard'); + expect(document.activeElement.id).to.be.equal('tooltip-trigger'); }); it('closes on Esc keypress', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const willCloseEventSpy = await page.spyOnEvent(events.willClose); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); - const dialog = await page.find('sbb-tooltip >>> dialog'); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); + const willCloseEventSpy = new EventSpy(events.willClose); + const didCloseEventSpy = new EventSpy(events.didClose); + const dialog = element.shadowRoot.querySelector('dialog'); - trigger.triggerEvent('click'); - await page.waitForChanges(); + trigger.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didOpenEventSpy.count).to.be.equal(1); - expect(dialog).toHaveAttribute('open'); + expect(dialog).to.have.attribute('open'); - await page.keyboard.down('Tab'); - await page.waitForChanges(); + await sendKeys({ down: 'Tab' }); + await waitForLitRender(element); - await page.keyboard.down('Escape'); - await page.waitForChanges(); + await sendKeys({ down: 'Escape' }); + await waitForLitRender(element); await waitForCondition(() => willCloseEventSpy.events.length === 1); - expect(willCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willCloseEventSpy.count).to.be.equal(1); await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didCloseEventSpy.count).to.be.equal(1); - expect(dialog).not.toHaveAttribute('open'); - expect(trigger).toEqualAttribute('data-focus-origin', 'keyboard'); - expect(await page.evaluate(() => document.activeElement.id)).toBe('tooltip-trigger'); + expect(dialog).not.to.have.attribute('open'); + expect(trigger).to.have.attribute('data-focus-origin', 'keyboard'); + expect(document.activeElement.id).to.be.equal('tooltip-trigger'); }); it('closes on interactive element click', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const willCloseEventSpy = await page.spyOnEvent(events.willClose); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); - const dialog = await page.find('sbb-tooltip >>> dialog'); - const tooltipLink = await page.find('sbb-tooltip > sbb-link'); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); + const willCloseEventSpy = new EventSpy(events.willClose); + const didCloseEventSpy = new EventSpy(events.didClose); + const dialog = element.shadowRoot.querySelector('dialog'); + const tooltipLink = document.querySelector('sbb-tooltip > sbb-link') as HTMLElement; - await trigger.triggerEvent('click'); - await page.waitForChanges(); + trigger.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didOpenEventSpy.count).to.be.equal(1); - expect(dialog).toHaveAttribute('open'); - expect(tooltipLink).not.toBeNull(); + expect(dialog).to.have.attribute('open'); + expect(tooltipLink).not.to.be.null; - await tooltipLink.click(); - await page.waitForChanges(); + tooltipLink.click(); + await waitForLitRender(element); await waitForCondition(() => willCloseEventSpy.events.length === 1); - expect(willCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willCloseEventSpy.count).to.be.equal(1); await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didCloseEventSpy.count).to.be.equal(1); - expect(dialog).not.toHaveAttribute('open'); - expect(trigger).toEqualAttribute('data-focus-origin', 'mouse'); - expect(await page.evaluate(() => document.activeElement.id)).toBe('tooltip-trigger'); + expect(dialog).not.to.have.attribute('open'); + expect(trigger).to.have.attribute('data-focus-origin', 'mouse'); + expect(document.activeElement.id).to.be.equal('tooltip-trigger'); }); it('closes on interactive element click by keyboard', async () => { - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); - const tooltipLink = await page.find('sbb-tooltip > sbb-link'); + const didOpenEventSpy = new EventSpy(events.didOpen); + const didCloseEventSpy = new EventSpy(events.didClose); + const tooltipLink = document.querySelector('sbb-tooltip > sbb-link') as HTMLElement; - await trigger.triggerEvent('click'); - await page.waitForChanges(); + trigger.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didOpenEventSpy.count).to.be.equal(1); - expect(tooltipLink).not.toBeNull(); + expect(tooltipLink).not.to.be.null; - await tooltipLink.focus(); - await page.keyboard.down('Enter'); - await page.waitForChanges(); + tooltipLink.focus(); + await sendKeys({ down: 'Enter' }); + await waitForLitRender(element); await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didCloseEventSpy.count).to.be.equal(1); - expect(trigger).toEqualAttribute('data-focus-origin', 'keyboard'); - expect(await page.evaluate(() => document.activeElement.id)).toBe('tooltip-trigger'); + expect(trigger).to.have.attribute('data-focus-origin', 'keyboard'); + expect(document.activeElement.id).to.be.equal('tooltip-trigger'); }); it('is correctly positioned on screen', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); - await page.setViewport({ width: 1200, height: 800 }); - const dialog = await page.find('sbb-tooltip >>> dialog'); + await setViewport({ width: 1200, height: 800 }); + const dialog = element.shadowRoot.querySelector('dialog'); - trigger.triggerEvent('click'); - await page.waitForChanges(); + trigger.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didOpenEventSpy.count).to.be.equal(1); - expect(dialog).toHaveAttribute('open'); + expect(dialog).to.have.attribute('open'); - const buttonHeight = await page.evaluate(() => - getComputedStyle(document.documentElement).getPropertyValue( - `--sbb-size-button-l-min-height-large`, - ), + const buttonHeight = getComputedStyle(document.documentElement).getPropertyValue( + `--sbb-size-button-l-min-height-large`, ); - expect(buttonHeight.trim()).toBe('3.5rem'); + expect(buttonHeight.trim()).to.be.equal('3.5rem'); const buttonHeightPx = parseFloat(buttonHeight) * 16; - expect(await page.evaluate(() => document.querySelector('sbb-button').offsetHeight)).toBe( - buttonHeightPx, - ); - expect(await page.evaluate(() => document.querySelector('sbb-button').offsetTop)).toBe(0); - expect(await page.evaluate(() => document.querySelector('sbb-button').offsetLeft)).toBe(0); + expect(document.querySelector('sbb-button').offsetHeight).to.be.equal(buttonHeightPx); + expect(document.querySelector('sbb-button').offsetTop).to.be.equal(0); + expect(document.querySelector('sbb-button').offsetLeft).to.be.equal(0); // Expect dialog offsetTop to be equal to the trigger height + the dialog offset (8px) - expect( - await page.evaluate( - () => document.querySelector('sbb-tooltip').shadowRoot.querySelector('dialog').offsetTop, - ), - ).toBe(buttonHeightPx + 16); - expect( - await page.evaluate( - () => document.querySelector('sbb-tooltip').shadowRoot.querySelector('dialog').offsetLeft, - ), - ).toBe(0); + expect(element.shadowRoot.querySelector('dialog').offsetTop).to.be.equal(buttonHeightPx + 16); + expect(element.shadowRoot.querySelector('dialog').offsetLeft).to.be.equal(0); }); it('sets the focus on the dialog content when the tooltip is opened by click', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const dialog = await page.find('sbb-tooltip >>> dialog'); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); + // NOTE: the ">>>" operator is not supported outside stencil. (convert it to something like "element.shadowRoot.querySelector(...)") + const dialog = element.shadowRoot.querySelector('dialog'); - trigger.triggerEvent('click'); - await page.waitForChanges(); + trigger.dispatchEvent(new CustomEvent('click')); + await waitForLitRender(element); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - - expect(dialog).toHaveAttribute('open'); - await page.waitForChanges(); - expect( - await page.evaluate( - () => document.querySelector('sbb-tooltip').shadowRoot.activeElement.className, - ), - ).toBe('sbb-tooltip__content'); + expect(didOpenEventSpy.count).to.be.equal(1); + expect(dialog).to.have.attribute('open'); + expect(element.shadowRoot.activeElement.className).to.be.equal('sbb-tooltip__content'); - await page.keyboard.down('Tab'); - await page.waitForChanges(); + await sendKeys({ down: 'Tab' }); + await waitForLitRender(element); - expect(await page.evaluate(() => document.activeElement.id)).toBe('tooltip-link'); + expect(document.activeElement.id).to.be.equal('tooltip-link'); }); it('sets the focus to the first focusable element when the tooltip is opened by keyboard', async () => { - const willOpenEventSpy = await page.spyOnEvent(events.willOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const dialog = await page.find('sbb-tooltip >>> dialog'); + const willOpenEventSpy = new EventSpy(events.willOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); + // NOTE: the ">>>" operator is not supported outside stencil. (convert it to something like "element.shadowRoot.querySelector(...)") + const dialog = element.shadowRoot.querySelector('dialog'); - await page.keyboard.down('Tab'); - await page.waitForChanges(); + await sendKeys({ down: 'Tab' }); + await waitForLitRender(element); - await page.keyboard.down('Enter'); - await page.waitForChanges(); + await sendKeys({ down: 'Enter' }); + await waitForLitRender(element); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didOpenEventSpy.count).to.be.equal(1); + expect(dialog).to.have.attribute('open'); - expect(dialog).toHaveAttribute('open'); - - await page.waitForChanges(); - expect(await page.evaluate(() => document.activeElement.id)).toBe('tooltip'); + await waitForLitRender(element); + expect(document.activeElement.id).to.be.equal('tooltip'); expect( - await page.evaluate( - () => - document.activeElement.shadowRoot.activeElement === - document.activeElement.shadowRoot.querySelector('[sbb-tooltip-close]'), - ), - ).toBe(true); + document.activeElement.shadowRoot.activeElement === + document.activeElement.shadowRoot.querySelector('[sbb-tooltip-close]'), + ).to.be.equal(true); }); it('should set correct focus attribute on trigger after backdrop click', async () => { - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); + const didOpenEventSpy = new EventSpy(events.didOpen); + const didCloseEventSpy = new EventSpy(events.didClose); - await element.callMethod('open'); - await page.waitForChanges(); + element.open(); + await waitForLitRender(element); await waitForCondition(() => didOpenEventSpy.events.length === 1); - await page.waitForChanges(); + await waitForLitRender(element); // Simulate backdrop click - await page.evaluate(() => - document.dispatchEvent(new MouseEvent('mousedown', { buttons: 1, clientX: 1 })), - ); - await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerup'))); + document.dispatchEvent(new MouseEvent('mousedown', { buttons: 1, clientX: 1 })); + window.dispatchEvent(new PointerEvent('pointerup')); await waitForCondition(() => didCloseEventSpy.events.length === 1); - await page.waitForChanges(); + await waitForLitRender(element); - expect(trigger).toEqualAttribute('data-focus-origin', 'mouse'); - expect(await page.evaluate(() => document.activeElement.id)).toBe('tooltip-trigger'); + expect(trigger).to.have.attribute('data-focus-origin', 'mouse'); + expect(document.activeElement.id).to.be.equal('tooltip-trigger'); }); it('should set correct focus attribute on trigger after backdrop click on an interactive element', async () => { - const interactiveBackgroundElement = await page.find('#interactive-background-element'); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); + const interactiveBackgroundElement = document.querySelector( + '#interactive-background-element', + ) as HTMLElement; + const didOpenEventSpy = new EventSpy(events.didOpen); + const didCloseEventSpy = new EventSpy(events.didClose); - await element.callMethod('open'); - await page.waitForChanges(); + element.open(); + await waitForLitRender(element); await waitForCondition(() => didOpenEventSpy.events.length === 1); - await page.waitForChanges(); + await waitForLitRender(element); - await interactiveBackgroundElement.click(); - await page.waitForChanges(); + const interactiveElementPosition = interactiveBackgroundElement.getBoundingClientRect(); + await sendMouse({ + type: 'click', + position: [interactiveElementPosition.x, interactiveElementPosition.y], + }); + await waitForLitRender(element); await waitForCondition(() => didCloseEventSpy.events.length === 1); - await page.waitForChanges(); + await waitForLitRender(element); - expect(await page.evaluate(() => document.activeElement.id)).toBe( - 'interactive-background-element', - ); + expect(document.activeElement.id).to.be.equal('interactive-background-element'); }); it('should close an open tooltip when another one is opened', async () => { - page = await newE2EPage(); - await page.setContent(` + fixtureCleanup(); + await fixture(html` + Other interactive element Tooltip trigger Another tooltip trigger @@ -424,60 +401,63 @@ describe('sbb-tooltip', () => { Another tooltip content. - Other interactive element `); - trigger = await page.find('#tooltip-trigger'); - const secondTrigger = await page.find('#another-tooltip-trigger'); - element = await page.find('#tooltip'); - const secondElement = await page.find('#another-tooltip'); - const dialog = await page.find('#tooltip >>> dialog'); - const secondDialog = await page.find('#another-tooltip >>> dialog'); - - const willOpenEventSpy = await page.spyOnEvent(events.didOpen); - const didOpenEventSpy = await page.spyOnEvent(events.didOpen); - const willCloseEventSpy = await page.spyOnEvent(events.didClose); - const didCloseEventSpy = await page.spyOnEvent(events.didClose); - - expect(secondTrigger).not.toBeNull(); - expect(secondElement).not.toBeNull(); - expect(secondDialog).not.toBeNull(); - - await trigger.focus(); - await trigger.press('Space'); - await page.waitForChanges(); + trigger = document.querySelector('#tooltip-trigger'); + const secondTrigger = document.querySelector('#another-tooltip-trigger'); + element = document.querySelector('#tooltip'); + const secondElement: SbbTooltip = document.querySelector('#another-tooltip'); + + const dialog: HTMLDialogElement = document + .querySelector('#tooltip') + .shadowRoot.querySelector('dialog'); + const secondDialog = document + .querySelector('#another-tooltip') + .shadowRoot.querySelector('dialog'); + + const willOpenEventSpy = new EventSpy(events.didOpen); + const didOpenEventSpy = new EventSpy(events.didOpen); + const willCloseEventSpy = new EventSpy(events.didClose); + const didCloseEventSpy = new EventSpy(events.didClose, element); + + expect(secondTrigger).not.to.be.null; + expect(secondElement).not.to.be.null; + expect(secondDialog).not.to.be.null; + + trigger.focus(); + await sendKeys({ press: 'Space' }); + await waitForLitRender(element); await waitForCondition(() => willOpenEventSpy.events.length === 1); - expect(willOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(1); await waitForCondition(() => didOpenEventSpy.events.length === 1); - expect(didOpenEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); - expect(dialog).toHaveAttribute('open'); - await trigger.press('Tab'); - expect(await page.evaluate(() => document.activeElement.id)).toBe('another-tooltip-trigger'); + await waitForLitRender(element); + expect(didOpenEventSpy.count).to.be.equal(1); - await page.keyboard.down('Enter'); + expect(dialog.open).to.be.equal(true); + trigger.focus(); + await sendKeys({ press: 'Tab' }); + + expect(document.activeElement.id).to.be.equal('another-tooltip-trigger'); + + await sendKeys({ press: 'Space' }); + await waitForLitRender(element); await waitForCondition(() => willCloseEventSpy.events.length === 1); - expect(willCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(willCloseEventSpy.count).to.be.equal(1); await waitForCondition(() => didCloseEventSpy.events.length === 1); - expect(didCloseEventSpy).toHaveReceivedEventTimes(1); - await page.waitForChanges(); + expect(didCloseEventSpy.count).to.be.equal(1); + await waitForLitRender(element); - expect(dialog).not.toHaveAttribute('open'); + expect(dialog).not.to.have.attribute('open'); await waitForCondition(() => willOpenEventSpy.events.length === 2); - expect(willOpenEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); + expect(willOpenEventSpy.count).to.be.equal(2); await waitForCondition(() => didOpenEventSpy.events.length === 2); - expect(didOpenEventSpy).toHaveReceivedEventTimes(2); - await page.waitForChanges(); - - expect(secondDialog).toHaveAttribute('open'); + expect(didOpenEventSpy.count).to.be.equal(2); + expect(secondDialog).to.have.attribute('open'); }); }); diff --git a/src/components/sbb-tooltip/sbb-tooltip.spec.ts b/src/components/sbb-tooltip/sbb-tooltip.spec.ts index 2799e750f39..93087a22b3f 100644 --- a/src/components/sbb-tooltip/sbb-tooltip.spec.ts +++ b/src/components/sbb-tooltip/sbb-tooltip.spec.ts @@ -1,17 +1,22 @@ -import { SbbTooltip } from './sbb-tooltip'; -import { newSpecPage } from '@stencil/core/testing'; +import './sbb-tooltip'; + +import { expect, fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; import { i18nCloseTooltip } from '../../global/i18n'; describe('sbb-tooltip', () => { it('renders', async () => { - const { root } = await newSpecPage({ - components: [SbbTooltip], - html: '', - }); + const root = await fixture(html``); - expect(root).toEqualHtml(` + expect(root).dom.to.be.equal( + ` - + + + `, + ); + expect(root).shadowDom.to.be.equal( + `
@@ -21,13 +26,12 @@ describe('sbb-tooltip', () => { - +
- -
- `); + `, + ); }); }); diff --git a/src/components/sbb-tooltip/sbb-tooltip.stories.tsx b/src/components/sbb-tooltip/sbb-tooltip.stories.tsx index beadd5a44ef..b45a678f9ba 100644 --- a/src/components/sbb-tooltip/sbb-tooltip.stories.tsx +++ b/src/components/sbb-tooltip/sbb-tooltip.stories.tsx @@ -7,8 +7,11 @@ import { userEvent, within } from '@storybook/testing-library'; import { waitForComponentsReady } from '../../global/testing/wait-for-components-ready'; import { waitForStablePosition } from '../../global/testing/wait-for-stable-position'; import { withActions } from '@storybook/addon-actions/decorator'; -import type { Args, ArgTypes, Decorator, Meta, StoryObj } from '@storybook/html'; +import type { Args, ArgTypes, Decorator, Meta, StoryObj } from '@storybook/web-components'; import type { InputType } from '@storybook/types'; +import './sbb-tooltip'; +import '../sbb-tooltip-trigger'; +import '../sbb-link'; async function commonPlayStory(canvasElement): Promise { const canvas = within(canvasElement); diff --git a/src/components/sbb-tooltip/sbb-tooltip.tsx b/src/components/sbb-tooltip/sbb-tooltip.tsx index a83d7e67c1d..a6b37977621 100644 --- a/src/components/sbb-tooltip/sbb-tooltip.tsx +++ b/src/components/sbb-tooltip/sbb-tooltip.tsx @@ -1,150 +1,148 @@ -import { - Component, - ComponentInterface, - Element, - Event, - EventEmitter, - h, - Host, - JSX, - Method, - Prop, - State, - Watch, -} from '@stencil/core'; -import { SbbOverlayState } from '../../components'; +import { CSSResult, LitElement, PropertyValues, TemplateResult, html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; import { FocusTrap, IS_FOCUSABLE_QUERY, assignId, + getFirstFocusableElement, sbbInputModalityDetector, setModalityOnNextFocus, - getFirstFocusableElement, } from '../../global/a11y'; -import { findReferencedElement, isValidAttribute } from '../../global/dom'; +import { findReferencedElement, isValidAttribute, setAttribute } from '../../global/dom'; import { - documentLanguage, + EventEmitter, HandlerRepository, - languageChangeHandlerAspect, composedPathHasAttribute, + documentLanguage, + languageChangeHandlerAspect, } from '../../global/eventing'; import { i18nCloseTooltip } from '../../global/i18n'; import { Alignment, + SbbOverlayState, + getElementPosition, + isEventOnElement, removeAriaOverlayTriggerAttributes, setAriaOverlayTriggerAttributes, - isEventOnElement, - getElementPosition, } from '../../global/overlay'; +import Style from './sbb-tooltip.scss?lit&inline'; const VERTICAL_OFFSET = 16; const HORIZONTAL_OFFSET = 32; let nextId = 0; -const tooltipsRef = new Set(); +const tooltipsRef = new Set(); /** * @slot unnamed - Use this slot to project any content inside the tooltip. */ -@Component({ - shadow: true, - styleUrl: 'sbb-tooltip.scss', - tag: 'sbb-tooltip', -}) -export class SbbTooltip implements ComponentInterface { +export const events = { + willOpen: 'will-open', + didOpen: 'did-open', + willClose: 'will-close', + didClose: 'did-close', +}; + +@customElement('sbb-tooltip') +export class SbbTooltip extends LitElement { + public static override styles: CSSResult = Style; + /** * The element that will trigger the tooltip dialog. * Accepts both a string (id of an element) or an HTML element. */ - @Prop() public trigger: string | HTMLElement; + @property() public trigger: string | HTMLElement; /** * Whether the close button should be hidden. */ - @Prop() public hideCloseButton?: boolean = false; + @property({ attribute: 'hide-close-button', type: Boolean }) public hideCloseButton?: boolean = + false; /** * Whether the tooltip should be triggered on hover. */ - @Prop() public hoverTrigger?: boolean = false; + @property({ attribute: 'hover-trigger', type: Boolean }) public hoverTrigger?: boolean = false; /** * Open the tooltip after a certain delay. */ - @Prop() public openDelay? = 0; + @property({ attribute: 'open-delay' }) public openDelay? = 0; /** * Close the tooltip after a certain delay. */ - @Prop() public closeDelay? = 0; + @property({ attribute: 'close-delay' }) public closeDelay? = 0; /** * Whether the animation is enabled. */ - @Prop({ reflect: true }) public disableAnimation = false; + @property({ attribute: 'disable-animation', reflect: true, type: Boolean }) + public disableAnimation = false; /** * This will be forwarded as aria-label to the close button element. */ - @Prop() public accessibilityCloseLabel: string | undefined; + @property({ attribute: 'accessibility-close-label' }) public accessibilityCloseLabel: + | string + | undefined; /** * The state of the tooltip. */ private set _state(state: SbbOverlayState) { - this._element.dataset.state = state; + this.dataset.state = state; } private get _state(): SbbOverlayState { - return this._element.dataset.state as SbbOverlayState; + return this.dataset.state as SbbOverlayState; } /** * The alignment of the tooltip relative to the trigger. */ - @State() private _alignment: Alignment; + @state() private _alignment: Alignment; - @State() private _currentLanguage = documentLanguage(); + @state() private _currentLanguage = documentLanguage(); /** * Emits whenever the tooltip starts the opening transition. */ - @Event({ + + private _willOpen: EventEmitter = new EventEmitter(this, events.willOpen, { bubbles: true, composed: true, - eventName: 'will-open', - }) - public willOpen: EventEmitter; + }); /** * Emits whenever the tooltip is opened. */ - @Event({ + + private _didOpen: EventEmitter = new EventEmitter(this, events.didOpen, { bubbles: true, composed: true, - eventName: 'did-open', - }) - public didOpen: EventEmitter; + }); /** * Emits whenever the tooltip begins the closing transition. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'will-close', - }) - public willClose: EventEmitter<{ closeTarget: HTMLElement }>; + + private _willClose: EventEmitter<{ closeTarget: HTMLElement }> = new EventEmitter( + this, + events.willClose, + { bubbles: true, composed: true }, + ); /** * Emits whenever the tooltip is closed. */ - @Event({ - bubbles: true, - composed: true, - eventName: 'did-close', - }) - public didClose: EventEmitter<{ closeTarget: HTMLElement }>; + + private _didClose: EventEmitter<{ closeTarget: HTMLElement }> = new EventEmitter( + this, + events.didClose, + { bubbles: true, composed: true }, + ); private _dialog: HTMLDialogElement; private _triggerElement: HTMLElement; @@ -161,18 +159,16 @@ export class SbbTooltip implements ComponentInterface { private _closeTimeout: ReturnType; private _tooltipId = `sbb-tooltip-${++nextId}`; - @Element() private _element!: HTMLElement; - private _handlerRepository = new HandlerRepository( - this._element, + this, languageChangeHandlerAspect((l) => (this._currentLanguage = l)), ); /** * Opens the tooltip on trigger click. */ - @Method() - public async open(): Promise { + + public open(): void { if ((this._state !== 'closed' && this._state !== 'closing') || !this._dialog) { return; } @@ -180,11 +176,11 @@ export class SbbTooltip implements ComponentInterface { for (const tooltip of Array.from(tooltipsRef)) { const state = tooltip.getAttribute('data-state') as SbbOverlayState; if (state && (state === 'opened' || state === 'opening')) { - await tooltip.close(); + tooltip.close(); } } - this.willOpen.emit(); + this._willOpen.emit(); this._state = 'opening'; this._setTooltipPosition(); this._dialog.show(); @@ -195,14 +191,14 @@ export class SbbTooltip implements ComponentInterface { /** * Closes the tooltip. */ - @Method() - public async close(target?: HTMLElement): Promise { + + public close(target?: HTMLElement): void { if (this._state !== 'opened' && this._state !== 'opening') { return; } this._tooltipCloseElement = target; - this.willClose.emit({ closeTarget: this._tooltipCloseElement }); + this._willClose.emit({ closeTarget: this._tooltipCloseElement }); this._state = 'closing'; this._triggerElement?.setAttribute('aria-expanded', 'false'); } @@ -220,8 +216,8 @@ export class SbbTooltip implements ComponentInterface { } // Removes trigger click listener on trigger change. - @Watch('trigger') - public removeTriggerClickListener( + + private _removeTriggerClickListener( newValue: string | HTMLElement, oldValue: string | HTMLElement, ): void { @@ -232,27 +228,36 @@ export class SbbTooltip implements ComponentInterface { } } - public connectedCallback(): void { + public override connectedCallback(): void { + super.connectedCallback(); this._handlerRepository.connect(); // Validate trigger element and attach event listeners this._configure(this.trigger); this._state = 'closed'; - tooltipsRef.add(this._element as HTMLSbbTooltipElement); + tooltipsRef.add(this as SbbTooltip); + } + + public override willUpdate(changedProperties: PropertyValues): void { + // TODO: Verify parity + if (changedProperties.has('trigger')) { + this._removeTriggerClickListener(this.trigger, changedProperties.get('trigger')); + } } - public componentDidLoad(): void { + protected override firstUpdated(): void { if (this._hoverTrigger) { this._dialog.addEventListener('mouseenter', () => this._onDialogMouseEnter()); this._dialog.addEventListener('mouseleave', () => this._onDialogMouseLeave()); } } - public disconnectedCallback(): void { + public override disconnectedCallback(): void { + super.disconnectedCallback(); this._handlerRepository.disconnect(); this._tooltipController?.abort(); this._windowEventsController?.abort(); this._focusTrap.disconnect(); - tooltipsRef.delete(this._element as HTMLSbbTooltipElement); + tooltipsRef.delete(this as SbbTooltip); } // Check if the trigger is valid and attach click event listeners. @@ -272,7 +277,7 @@ export class SbbTooltip implements ComponentInterface { setAriaOverlayTriggerAttributes( this._triggerElement, 'dialog', - this._element.id || this._tooltipId, + this.id || this._tooltipId, this._state, ); @@ -340,7 +345,7 @@ export class SbbTooltip implements ComponentInterface { // Close the tooltip on click of any element that has the 'sbb-tooltip-close' attribute. private async _closeOnSbbTooltipCloseClick(event: Event): Promise { - const closeElement = composedPathHasAttribute(event, 'sbb-tooltip-close', this._element); + const closeElement = composedPathHasAttribute(event, 'sbb-tooltip-close', this); if (closeElement && !isValidAttribute(closeElement, 'disabled')) { clearTimeout(this._closeTimeout); @@ -400,9 +405,9 @@ export class SbbTooltip implements ComponentInterface { private _onTooltipAnimationEnd(event: AnimationEvent): void { if (event.animationName === 'open' && this._state === 'opening') { this._state = 'opened'; - this.didOpen.emit(); + this._didOpen.emit(); this._setTooltipFocus(); - this._focusTrap.trap(this._element); + this._focusTrap.trap(this); this._attachWindowEvents(); } else if (event.animationName === 'close' && this._state === 'closing') { this._state = 'closed'; @@ -414,7 +419,7 @@ export class SbbTooltip implements ComponentInterface { this._dialog.close(); // To enable focusing other element than the trigger, we need to call focus() a second time. elementToFocus?.focus(); - this.didClose.emit({ closeTarget: this._tooltipCloseElement }); + this._didClose.emit({ closeTarget: this._tooltipCloseElement }); this._windowEventsController?.abort(); this._focusTrap.disconnect(); } @@ -424,9 +429,9 @@ export class SbbTooltip implements ComponentInterface { private _setTooltipFocus(): void { if (sbbInputModalityDetector.mostRecentModality === 'keyboard') { const firstFocusable = - (this._element.shadowRoot.querySelector('[sbb-tooltip-close]') as HTMLElement) || + (this.shadowRoot.querySelector('[sbb-tooltip-close]') as HTMLElement) || getFirstFocusableElement( - Array.from(this._element.children).filter( + Array.from(this.children).filter( (e): e is HTMLElement => e instanceof window.HTMLElement, ), ); @@ -437,13 +442,9 @@ export class SbbTooltip implements ComponentInterface { // the focus-visible styles would be incorrectly applied this._tooltipContentElement.tabIndex = 0; this._tooltipContentElement.focus(); - this._element.addEventListener( - 'blur', - () => this._tooltipContentElement.removeAttribute('tabindex'), - { - once: true, - }, - ); + this.addEventListener('blur', () => this._tooltipContentElement.removeAttribute('tabindex'), { + once: true, + }); } } @@ -467,48 +468,60 @@ export class SbbTooltip implements ComponentInterface { this._triggerElement.clientWidth / 2 - 8; // half the size of the tooltip arrow - this._element.style.setProperty('--sbb-tooltip-position-x', `${tooltipPosition.left}px`); - this._element.style.setProperty('--sbb-tooltip-position-y', `${tooltipPosition.top}px`); - this._element.style.setProperty('--sbb-tooltip-arrow-position-x', `${arrowXPosition}px`); + this.style.setProperty('--sbb-tooltip-position-x', `${tooltipPosition.left}px`); + this.style.setProperty('--sbb-tooltip-position-y', `${tooltipPosition.top}px`); + this.style.setProperty('--sbb-tooltip-arrow-position-x', `${arrowXPosition}px`); } - public render(): JSX.Element { - const closeButton = ( + protected override render(): TemplateResult { + const closeButton = html` - ); - - return ( - this._tooltipId)}> -
- this._onTooltipAnimationEnd(event)} - ref={(dialogRef) => (this._dialog = dialogRef)} - class="sbb-tooltip" - role="tooltip" + `; + + // ## Migr: Host attributes ## + setAttribute(this, 'data-position', this._alignment?.vertical); + assignId(() => this._tooltipId)(this); + // #### + + return html` +
+ this._onTooltipAnimationEnd(event)} + ${ref((dialogRef) => (this._dialog = dialogRef as HTMLDialogElement))} + class="sbb-tooltip" + role="tooltip" + > +
this._closeOnSbbTooltipCloseClick(event)} + ${ref( + (tooltipContentRef) => + (this._tooltipContentElement = tooltipContentRef as HTMLElement), + )} + class="sbb-tooltip__content" > - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} -
this._closeOnSbbTooltipCloseClick(event)} - ref={(tooltipContentRef) => (this._tooltipContentElement = tooltipContentRef)} - class="sbb-tooltip__content" - > - - No content - - {!this.hideCloseButton && !this._hoverTrigger && closeButton} -
-
-
- - ); + + No content + + ${!this.hideCloseButton && !this._hoverTrigger ? closeButton : nothing} +
+ + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-tooltip': SbbTooltip; } }