From 6186d30f3693da5958847c833900dece2b40ef11 Mon Sep 17 00:00:00 2001 From: Mathis Poncet Date: Thu, 4 Dec 2025 15:03:57 +0100 Subject: [PATCH] fix(tooltip): preserve JS and event listener in the overlay --- CHANGELOG.md | 1 + .../tooltip/tooltip.e2e.playwright.ts | 172 ++++++++++++++++++ src/components/tooltip/tooltip.scss | 4 + src/components/tooltip/tooltip.tsx | 41 ++++- stories/components/tooltip/tooltip.stories.ts | 28 ++- 5 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 src/components/tooltip/tooltip.e2e.playwright.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 275729ec..ae662483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ for now removed features. - **doc**: apply selected theme on MDX docs pages - **grid**: default sort on a `wcs-grid-column` with grid pagination did not worked well - **modal**: actions was not re-render after setting `hide-action` attribute +- **tooltip**: preserve JS and events listeners in the overlay ### Security diff --git a/src/components/tooltip/tooltip.e2e.playwright.ts b/src/components/tooltip/tooltip.e2e.playwright.ts new file mode 100644 index 00000000..47ba9620 --- /dev/null +++ b/src/components/tooltip/tooltip.e2e.playwright.ts @@ -0,0 +1,172 @@ +import { setWcsContent } from '../../utils/playwright/test'; +import { test, E2EPage } from "@stencil/playwright"; + +import { expect } from "@playwright/test"; + +test.describe('Tooltip component', () => { + test('Shows tooltip when hovering the target element', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` + Hover me + Tooltip content + `); + const button = page.locator('wcs-button'); + + // When + await button.hover(); + await page.waitForChanges(); + + // Then + const tippyBox = page.locator('.tippy-box'); + expect(tippyBox).not.toBeNull(); + await expect(tippyBox).toHaveText('Tooltip content'); + }); + + test('Shows tooltip programmatically with show() method', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` + Target + Tooltip content + `); + const tooltip = page.locator('wcs-tooltip'); + + // When + await tooltip.evaluate((el: HTMLWcsTooltipElement) => el.show()); + + await tooltip.evaluate((el: HTMLWcsTooltipElement) => el.show()); + await page.waitForChanges(); + + // Then + const tippyBox = page.locator('.tippy-box'); + expect(tippyBox).not.toBeNull(); + }); + + test('Hides tooltip programmatically with hide() method', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` + Target + Tooltip content + `); + const tooltip = page.locator('wcs-tooltip'); + + // When + await tooltip.evaluate((el: HTMLWcsTooltipElement) => el.show()); + + await page.waitForChanges(); + await tooltip.evaluate((el: HTMLWcsTooltipElement) => el.hide()); + await page.waitForChanges(); + + // Then + const tippyBox = page.locator('.tippy-box[data-state="visible"]'); + await expect(tippyBox).not.toBeVisible(); + }); + + test('Updates tooltip content when content prop changes', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` + Target + Slot content + `); + const tooltip = page.locator('wcs-tooltip'); + + // When + await tooltip.evaluate((el: HTMLWcsTooltipElement) => el.show()); + + await page.waitForChanges(); + + let tippyBox = page.locator('.tippy-box'); + await expect(tippyBox).toHaveText('Initial content Slot content'); + + await tooltip.evaluate((el: HTMLWcsTooltipElement) => el.content = 'Updated content '); + await page.waitForChanges(); + + // Then + tippyBox = page.locator('.tippy-box'); + await expect(tippyBox).toHaveText('Updated content Slot content'); + }); + + test.describe('Interactive mode', () => { + test('Keeps tooltip visible when hovering inside interactive tooltip', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` + Hover me + + Interactive content + + `); + const button = page.locator('wcs-button'); + + // When + await button.hover(); + await page.waitForChanges(); + + const tippyBox = page.locator('.tippy-box'); + expect(tippyBox).not.toBeNull(); + + await page.hover('.tippy-content'); + + const visibleTippy = page.locator('.tippy-box[data-state="visible"]'); + expect(visibleTippy).not.toBeNull(); + }); + + test('Preserves event listeners on slotted content in interactive mode', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` + Click me + + + + `); + + const button = page.locator('wcs-button'); + + // When - open tooltip first (content is moved to tippy) + await button.click(); + await page.waitForChanges(); + + // Add click handler AFTER the tooltip is opened (elements are now in tippy) + await page.evaluate(() => { + (window as any).clickCount = 0; + const innerButton = document.getElementById('inner-button'); + if (innerButton) { + innerButton.addEventListener('click', () => { + (window as any).clickCount++; + }); + } + }); + + // Click the inner button inside the tooltip + await page.$eval('#inner-button', (elem: HTMLButtonElement) => elem.click()); + await page.waitForChanges(); + + // Then + const clickCount = await page.evaluate(() => (window as any).clickCount); + expect(clickCount).toBe(1); + }); + + test('Preserves event listeners on wcs-switch inside interactive tooltip', async ({ page }: { page: E2EPage }) => { + // Given + await setWcsContent(page, ` + Click me + + + + `); + + const button = page.locator('wcs-button'); + await button.click(); + await page.waitForChanges(); + + const wcsSwitch = page.locator('.tippy-content wcs-switch'); + expect(wcsSwitch).not.toBeNull(); + + const changeSpy = await wcsSwitch.spyOnEvent('wcsChange'); + + await wcsSwitch.click(); + await page.waitForChanges(); + + // Then + expect(changeSpy).toHaveReceivedEventTimes(1); + }); + }); +}); diff --git a/src/components/tooltip/tooltip.scss b/src/components/tooltip/tooltip.scss index ab668966..01141cb0 100644 --- a/src/components/tooltip/tooltip.scss +++ b/src/components/tooltip/tooltip.scss @@ -3,3 +3,7 @@ :host { display: none; } + +.wcs-tooltip-content-wrapper { + display: contents; +} diff --git a/src/components/tooltip/tooltip.tsx b/src/components/tooltip/tooltip.tsx index 74ba4dc7..0896ac67 100644 --- a/src/components/tooltip/tooltip.tsx +++ b/src/components/tooltip/tooltip.tsx @@ -174,11 +174,14 @@ export class Tooltip implements ComponentInterface { return } + const tooltipContent = this.getTooltipContentFromPropAndSlot(); + const isElementContent = tooltipContent instanceof HTMLElement; + this.tippyInstance = tippy(this.forElement, { appendTo: this.appendTo || (() => document.body), theme: this.theme, - allowHTML: true, - content: this.getTooltipContentFromPropAndSlot(), + allowHTML: !isElementContent, // Only needed for string content + content: tooltipContent, maxWidth: this.maxWidth, placement: this.position, delay: this.delay, @@ -239,7 +242,33 @@ export class Tooltip implements ComponentInterface { } } - private getTooltipContentFromPropAndSlot() { + private getTooltipContentFromPropAndSlot(): string | HTMLElement { + const hasSlottedContent = this.el.childElementCount > 0 || + (this.el.textContent && this.el.textContent.trim().length > 0); + + // For interactive mode with slotted content, use Element reference to preserve event listeners + // See: https://atomiks.github.io/tippyjs/v5/html-content/#element + if (this.interactive && hasSlottedContent) { + // We create a wrapper to hold all slotted content. + // The wrapper uses 'display: contents' in CSS so it doesn't affect the applied layout - + // it acts as if the children were direct children of the tippy content element. + const wrapper = document.createElement('div'); + wrapper.className = 'wcs-tooltip-content-wrapper'; + + if (this.content) { + const textNode = document.createTextNode(this.content); + wrapper.appendChild(textNode); + } + + // Move children to wrapper (preserves event listeners) + while (this.el.firstChild) { + wrapper.appendChild(this.el.firstChild); + } + + return wrapper; + } + + // For non-interactive or content-prop-only, use string (current behavior) if (this.content) { return this.content + this.el.innerHTML; } @@ -268,8 +297,12 @@ export class Tooltip implements ComponentInterface { @Watch('content') private updateTippyContent() { + const tooltipContent = this.getTooltipContentFromPropAndSlot(); + const isElementContent = tooltipContent instanceof HTMLElement; + this.tippyInstance?.setProps({ - content: this.getTooltipContentFromPropAndSlot() + allowHTML: !isElementContent, + content: tooltipContent }) } diff --git a/stories/components/tooltip/tooltip.stories.ts b/stories/components/tooltip/tooltip.stories.ts index 652017f1..521c9363 100644 --- a/stories/components/tooltip/tooltip.stories.ts +++ b/stories/components/tooltip/tooltip.stories.ts @@ -179,17 +179,33 @@ export const DynamicContent = { * components for uses that would not be covered by the dropdown component. * * **An interactive tooltip is called a popover.** + * + * In this example, click the button inside the tooltip to see the event listener preserved and working. */ export const Interactive = { - render: (args: TooltipArgs) => Template(args), + render: (args: TooltipArgs) => { + const tooltip_unique_element_id_idx = tooltip_unique_element_id++; + return html` +
+ Hover to show tooltip + +

Content title

+

Logoden biniou degemer mat an penn ar bed perak stourm nebeut draonienn ael berr.

+ Visit SNCF + alert('Button clicked inside tooltip!')}>Click me! +
+

Click the button in the tooltip to trigger an event

+
+ `; + }, args: { ...Default.args, position: 'bottom', - interactive: true, - tooltipInnerHtml: `

Content title

-

Logoden biniou degemer mat an penn ar bed perak stourm nebeut draonienn ael berr, soubañ torgenn seizhvet gwener araok eor kribañ troc’hañ gwenn vered tan.

-logoden-biniou -C'est un grand oui !` + interactive: true } } -- GitLab