Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions playwright/cps-ui-kit/directives/cps-tooltip.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,25 @@
[htmlCode]="examples.tooltipPositions.html">
<div class="tooltip-elems-group-row">
<cps-button
data-testid="bottom-position-tooltip"
cpsTooltip="Bottom tooltip"
tooltipPosition="bottom"
label="Bottom tooltip">
</cps-button>
<cps-button
data-testid="left-position-tooltip"
cpsTooltip="Left tooltip"
tooltipPosition="left"
label="Left tooltip">
</cps-button>
<cps-button
data-testid="right-position-tooltip"
cpsTooltip="Right tooltip"
tooltipPosition="right"
label="Right tooltip">
</cps-button>
<cps-button
data-testid="top-position-tooltip"
cpsTooltip="Top tooltip"
tooltipPosition="top"
label="Top tooltip">
Expand All @@ -32,12 +36,14 @@
[htmlCode]="examples.openingMethods.html">
<div class="tooltip-elems-group-elem-left">
<cps-button
data-testid="hover-open-tooltip"
label="Open on hover"
tooltipPosition="bottom"
cpsTooltip="Triggered on hover"
tooltipOpenOn="hover">
</cps-button>
<cps-button
data-testid="click-open-tooltip"
label="Open on click"
tooltipPosition="right"
cpsTooltip="Triggered on click"
Expand All @@ -46,17 +52,31 @@
</div>
</app-code-example>

<app-code-example
label="Open on focus only"
[htmlCode]="examples.focusOnlyTooltip.html">
<cps-button
data-testid="focus-only-tooltip"
label="Open on focus only"
tooltipPosition="bottom"
cpsTooltip="Triggered on focus only"
tooltipOpenOn="focus">
</cps-button>
</app-code-example>

<app-code-example
label="Long open and close delays"
[htmlCode]="examples.openCloseDelays.html">
<div class="tooltip-elems-group-elem-left">
<cps-button
data-testid="open-delay-tooltip"
cpsTooltip="1 second open delay"
label="Open delay"
tooltipPosition="bottom"
[tooltipOpenDelay]="1000">
</cps-button>
<cps-button
data-testid="close-delay-tooltip"
cpsTooltip="1 second close delay"
label="Close delay"
tooltipPosition="right"
Expand All @@ -69,6 +89,7 @@
label="Persistent tooltip with inner HTML and custom style"
[htmlCode]="examples.persistentHtmlTooltip.html">
<cps-button
data-testid="persistent-tooltip"
cpsTooltip="<span class='ttip-hdr'>Follow the link</span> <a href='https://www.google.com/' target='_blank' rel='noopener noreferrer'>Google</a> <a href='https://www.bing.com/' target='_blank' rel='noopener noreferrer'>Bing</a>"
tooltipPosition="right"
tooltipContentClass="my-tooltip-content"
Expand All @@ -83,6 +104,7 @@
[tsCode]="examples.disabledTooltipState.ts">
<div class="tooltip-elems-group-elem-left">
<cps-button
data-testid="disabled-state-tooltip"
tooltipPosition="bottom"
[label]="ttipEnabled ? 'Enabled tooltip' : 'Disabled tooltip'"
[tooltipDisabled]="!ttipEnabled"
Expand All @@ -93,4 +115,17 @@
[label]="ttipEnabled ? 'Deactivate' : 'Activate'"></cps-checkbox>
</div>
</app-code-example>

<app-code-example
label="Custom offset and max width"
[htmlCode]="examples.customOffsetMaxWidthTooltip.html">
<cps-button
data-testid="custom-offset-tooltip"
cpsTooltip="A longer tooltip message to demonstrate a constrained max width"
tooltipPosition="right"
[tooltipOffset]="'2rem'"
[tooltipMaxWidth]="'8rem'"
label="Custom offset & max width">
</cps-button>
</app-code-example>
</app-component-docs-viewer>
Original file line number Diff line number Diff line change
@@ -1,83 +1,104 @@
export const tooltipExamples: Record<string, { html: string; ts?: string }> = {
tooltipPositions: {
html: `
<cps-button
cpsTooltip="Bottom tooltip"
tooltipPosition="bottom"
label="Bottom tooltip">
</cps-button>
<cps-button
cpsTooltip="Left tooltip"
tooltipPosition="left"
label="Left tooltip">
</cps-button>
<cps-button
cpsTooltip="Right tooltip"
tooltipPosition="right"
label="Right tooltip">
</cps-button>
<cps-button
cpsTooltip="Top tooltip"
tooltipPosition="top"
label="Top tooltip">
</cps-button>`
},

openingMethods: {
html: `
<cps-button
label="Open on hover"
tooltipPosition="bottom"
cpsTooltip="Triggered on hover"
tooltipOpenOn="hover">
</cps-button>
<cps-button
label="Open on click"
tooltipPosition="right"
cpsTooltip="Triggered on click"
tooltipOpenOn="click">
</cps-button>`
},

focusOnlyTooltip: {
html: `
<cps-button
label="Open on focus only"
tooltipPosition="bottom"
cpsTooltip="Triggered on focus only"
tooltipOpenOn="focus">
</cps-button>`
},

openCloseDelays: {
html: `
<cps-button
cpsTooltip="1 second open delay"
label="Open delay"
tooltipPosition="bottom"
[tooltipOpenDelay]="1000">
</cps-button>
<cps-button
cpsTooltip="1 second close delay"
label="Close delay"
tooltipPosition="right"
[tooltipCloseDelay]="1000">
</cps-button>`
},

persistentHtmlTooltip: {
html: `
<cps-button
cpsTooltip="<span class='ttip-hdr'>Follow the link</span> <a href='https://www.google.com/' target='_blank' rel='noopener noreferrer'>Google</a> <a href='https://www.bing.com/' target='_blank' rel='noopener noreferrer'>Bing</a>"
tooltipPosition="right"
tooltipContentClass="my-tooltip-content"
label="Tooltip with clickable content"
[tooltipPersistent]="true">
</cps-button>`
},

disabledTooltipState: {
html: `
<cps-button
tooltipPosition="bottom"
[label]="ttipEnabled ? 'Enabled tooltip' : 'Disabled tooltip'"
[tooltipDisabled]="!ttipEnabled"
cpsTooltip="<span><strong>Some bold text</strong></span>">
</cps-button>
<cps-checkbox
[(ngModel)]="ttipEnabled"
[label]="ttipEnabled ? 'Deactivate' : 'Activate'"></cps-checkbox>`,
ts: `
ttipEnabled = false;`
},

customOffsetMaxWidthTooltip: {
html: `
<cps-button
cpsTooltip="A longer tooltip message to demonstrate a constrained max width"
tooltipPosition="right"
[tooltipOffset]="'2rem'"
[tooltipMaxWidth]="'8rem'"
label="Custom offset & max width">
</cps-button>`
}
};

Check warning on line 104 in projects/composition/src/app/pages/tooltip-page/tooltip-page.examples.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ describe('CpsTooltipDirective', () => {
document.body.querySelector('.cps-tooltip');

expect(tooltipElement).toBeTruthy();
expect(tooltipElement?.innerHTML).toBe(
'<div class="cps-tooltip-content">Add your text to this tooltip</div>'
const content = tooltipElement?.querySelector(
'[data-testid="cps-tooltip-content"]'
);
Comment thread
fateeand marked this conversation as resolved.
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')
Expand All @@ -104,9 +106,11 @@ describe('CpsTooltipDirective', () => {
document.body.querySelector('.cps-tooltip');

expect(tooltipElement).toBeTruthy();
expect(tooltipElement?.innerHTML).toBe(
'<div class="cps-tooltip-content"><h1>Legit tooltip</h1></div>'
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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading