diff --git a/playwright/cps-ui-kit/directives/cps-tooltip.spec.ts b/playwright/cps-ui-kit/directives/cps-tooltip.spec.ts new file mode 100644 index 000000000..3853ff1e6 --- /dev/null +++ b/playwright/cps-ui-kit/directives/cps-tooltip.spec.ts @@ -0,0 +1,188 @@ +import { test, expect, type Page, type Locator } from '@playwright/test'; + +function button(page: Page, testId: string): Locator { + return page.getByTestId(testId); +} + +function tooltip(page: Page): Locator { + return page.getByTestId('cps-tooltip'); +} + +function gapBetween( + targetBox: { x: number; y: number; width: number; height: number }, + tooltipBox: { x: number; y: number; width: number; height: number } +): number { + const horizontalOverlap = + tooltipBox.x < targetBox.x + targetBox.width && + tooltipBox.x + tooltipBox.width > targetBox.x; + if (!horizontalOverlap) { + return tooltipBox.x >= targetBox.x + targetBox.width + ? tooltipBox.x - (targetBox.x + targetBox.width) + : targetBox.x - (tooltipBox.x + tooltipBox.width); + } + return tooltipBox.y >= targetBox.y + targetBox.height + ? tooltipBox.y - (targetBox.y + targetBox.height) + : targetBox.y - (tooltipBox.y + tooltipBox.height); +} + +test.describe('cps-tooltip', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tooltip'); + }); + + test.describe('Real click-trigger isolation from hover', () => { + test('hovering does nothing, clicking shows the real tooltip', async ({ + page + }) => { + const target = button(page, 'click-open-tooltip'); + + await target.hover(); + await expect(tooltip(page)).toHaveCount(0); + + await target.click(); + await expect(tooltip(page)).toBeVisible(); + await expect(tooltip(page)).toHaveText('Triggered on click'); + }); + }); + + test.describe('Real focus-only-trigger isolation from hover', () => { + test('hovering the target does not show the tooltip', async ({ page }) => { + const target = button(page, 'focus-only-tooltip'); + + await target.hover(); + await expect(tooltip(page)).toHaveCount(0); + }); + + test('real keyboard focus shows the real tooltip', async ({ page }) => { + const target = button(page, 'focus-only-tooltip'); + const innerButton = target.getByTestId('cps-button'); + + await innerButton.focus(); + const ownTooltip = page.getByRole('tooltip', { + name: 'Triggered on focus only' + }); + await expect(ownTooltip).toBeVisible(); + }); + }); + + test.describe('Real custom open-delay timing', () => { + test('the tooltip is not visible immediately after hover but appears within the configured delay', async ({ + page + }) => { + const target = button(page, 'open-delay-tooltip'); + + await target.hover(); + await expect(tooltip(page)).not.toBeVisible({ timeout: 200 }); + await expect(tooltip(page)).toBeVisible({ timeout: 1500 }); + }); + }); + + test.describe('Real custom close-delay timing', () => { + test('the tooltip stays visible briefly after mouse-leave before disappearing', async ({ + page + }) => { + const target = button(page, 'close-delay-tooltip'); + + await target.hover(); + await expect(tooltip(page)).toBeVisible(); + + await page.mouse.move(0, 0); + await expect(tooltip(page)).toBeVisible({ timeout: 200 }); + await expect(tooltip(page)).toHaveCount(0, { timeout: 1500 }); + }); + }); + + test.describe('tooltipDisabled renders no popup at all', () => { + test('hovering a disabled tooltip target creates no real tooltip node', async ({ + page + }) => { + const target = button(page, 'disabled-state-tooltip'); + + await target.hover(); + await expect(tooltip(page)).toHaveCount(0); + }); + }); + + test.describe('Real tooltipMaxWidth enforcement', () => { + test('the real rendered tooltip width respects the configured max width', async ({ + page + }) => { + const target = button(page, 'custom-offset-tooltip'); + + await target.hover(); + const box = await tooltip(page).boundingBox(); + if (!box) throw new Error('boundingBox() returned null'); + + const maxWidthPx = await page.evaluate( + () => + parseFloat(getComputedStyle(document.documentElement).fontSize) * 8 + ); + expect(box.width).toBeLessThanOrEqual(maxWidthPx + 1); + }); + }); + + test.describe('Real tooltipOffset distance', () => { + test('the real gap between target and tooltip matches the configured offset', async ({ + page + }) => { + const target = button(page, 'custom-offset-tooltip'); + + await target.hover(); + const targetBox = await target.boundingBox(); + const tooltipBox = await tooltip(page).boundingBox(); + if (!targetBox || !tooltipBox) + throw new Error('boundingBox() returned null'); + + const rootFontSizePx = await page.evaluate(() => + parseFloat(getComputedStyle(document.documentElement).fontSize) + ); + const gap = gapBetween(targetBox, tooltipBox); + expect(gap).toBeGreaterThan(rootFontSizePx * 1.5); + expect(gap).toBeLessThan(rootFontSizePx * 2.5); + }); + }); + + test.describe('Real click-trigger aria-live announce', () => { + test('clicking creates a real self-removing aria-live region with the tooltip text', async ({ + page + }) => { + const target = button(page, 'click-open-tooltip'); + + await target.click(); + const announce = page.locator('.cps-sr-only[aria-live="assertive"]'); + await expect(announce).toHaveText('Triggered on click'); + }); + }); + + test.describe('Real destroy on scroll and resize', () => { + test('a real scroll event immediately removes the tooltip with no lingering node', async ({ + page + }) => { + const target = button(page, 'bottom-position-tooltip'); + + await target.hover(); + await expect(tooltip(page)).toBeVisible(); + + await page.evaluate(() => window.dispatchEvent(new Event('scroll'))); + + await expect(tooltip(page)).toHaveCount(0); + }); + + test('a real window resize immediately removes the tooltip with no lingering node', async ({ + page + }) => { + const target = button(page, 'bottom-position-tooltip'); + const originalSize = page.viewportSize(); + + await target.hover(); + await expect(tooltip(page)).toBeVisible(); + + await page.setViewportSize({ + width: (originalSize?.width ?? 1280) - 10, + height: originalSize?.height ?? 720 + }); + + await expect(tooltip(page)).toHaveCount(0); + }); + }); +}); diff --git a/projects/composition/src/app/pages/tooltip-page/tooltip-page.component.html b/projects/composition/src/app/pages/tooltip-page/tooltip-page.component.html index 8db2d7ebb..3a37826bf 100644 --- a/projects/composition/src/app/pages/tooltip-page/tooltip-page.component.html +++ b/projects/composition/src/app/pages/tooltip-page/tooltip-page.component.html @@ -5,21 +5,25 @@ [htmlCode]="examples.tooltipPositions.html">
@@ -32,12 +36,14 @@ [htmlCode]="examples.openingMethods.html">
+ + + + +
+ + + + + diff --git a/projects/composition/src/app/pages/tooltip-page/tooltip-page.examples.ts b/projects/composition/src/app/pages/tooltip-page/tooltip-page.examples.ts index 1b8999d43..ec64216af 100644 --- a/projects/composition/src/app/pages/tooltip-page/tooltip-page.examples.ts +++ b/projects/composition/src/app/pages/tooltip-page/tooltip-page.examples.ts @@ -39,6 +39,16 @@ export const tooltipExamples: Record = {
` }, + focusOnlyTooltip: { + html: ` + +` + }, + openCloseDelays: { html: ` = { [label]="ttipEnabled ? 'Deactivate' : 'Activate'">`, ts: ` ttipEnabled = false;` + }, + + customOffsetMaxWidthTooltip: { + html: ` + +` } }; diff --git a/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.spec.ts b/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.spec.ts index 9b97d1931..9769f22b0 100644 --- a/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.spec.ts +++ b/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.spec.ts @@ -77,9 +77,11 @@ describe('CpsTooltipDirective', () => { document.body.querySelector('.cps-tooltip'); expect(tooltipElement).toBeTruthy(); - expect(tooltipElement?.innerHTML).toBe( - '
Add your text to this tooltip
' + const content = tooltipElement?.querySelector( + '[data-testid="cps-tooltip-content"]' ); + expect(content).toBeTruthy(); + expect(content?.textContent).toBe('Add your text to this tooltip'); // Angular informs about stripping some content during sanitization expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('sanitizing HTML stripped some content') @@ -104,9 +106,11 @@ describe('CpsTooltipDirective', () => { document.body.querySelector('.cps-tooltip'); expect(tooltipElement).toBeTruthy(); - expect(tooltipElement?.innerHTML).toBe( - '

Legit tooltip

' + const content = tooltipElement?.querySelector( + '[data-testid="cps-tooltip-content"]' ); + expect(content).toBeTruthy(); + expect(content?.querySelector('h1')?.textContent).toBe('Legit tooltip'); divElement.triggerEventHandler('mouseleave', null); legitComponentFixture.detectChanges(); diff --git a/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts b/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts index 21c577ab9..ed0314f32 100644 --- a/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts +++ b/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts @@ -269,10 +269,12 @@ export class CpsTooltipDirective implements OnDestroy { this._domSanitizer.sanitize(SecurityContext.HTML, this.tooltip()) || 'Add your text to this tooltip'; popupContent.className = this.tooltipContentClass(); + popupContent.setAttribute('data-testid', 'cps-tooltip-content'); this._popup.appendChild(popupContent); this._popup.classList.add('cps-tooltip'); this._popup.style.maxWidth = convertSize(this.tooltipMaxWidth()); this._popup.setAttribute('role', 'tooltip'); + this._popup.setAttribute('data-testid', 'cps-tooltip'); this._document.body.appendChild(this._popup); this._ariaTarget?.setAttribute( 'aria-description',