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
17 changes: 17 additions & 0 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ <h3>Programmatic Dismiss</h3>
</div>
</div>

<div class="tooltip-section">
<h3>Auto-Scroll Into View</h3>
<p>Targets that exist in the page but are outside the viewport (or scrolled out of a container) get smoothly scrolled into view before the tooltip appears. Scrolling only happens when the SDK can guarantee the tooltip will render successfully.</p>
<div class="tooltip-btn-row">
<button class="tooltip-target btn-blue" onclick="showTooltipAt('#offscreen-target', 'top')">Offscreen Page Target ↓</button>
<button class="tooltip-target btn-orange" onclick="showTooltipAt('#scroll-item-8', 'right')">Scroll Container Target (Feature H)</button>
</div>
</div>

<div class="tooltip-section">
<h3>Tooltip Event Log</h3>
<div class="tooltip-event-log" id="tooltipEventLog">
Expand All @@ -109,6 +118,14 @@ <h3>Tooltip Event Log</h3>
</div>
</div>

<div style="height: 120vh;"></div>
<div style="text-align: center; padding: 40px 20px;">
<button id="offscreen-target" class="tooltip-target btn-green" onclick="showTooltipAt('#offscreen-target', 'top')" style="font-size: 16px; padding: 14px 28px;">
I'm the offscreen target
</button>
<p style="margin-top: 12px; color: #666; font-size: 14px;">This element starts below the fold. The SDK scrolled here because it predicted a valid tooltip placement.</p>
</div>

<div class="config-form-sticky">
<div class="config-form-header" onclick="toggleConfigForm()">
<span>⚙️ Configuration Override & Debugging</span>
Expand Down
86 changes: 60 additions & 26 deletions src/managers/message-component-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from './message-component-manager';
import { log } from '../utilities/log';
import { resolveMessageProperties } from './gist-properties-manager';
import { positionTooltip } from './tooltip-position-manager';
import { positionTooltip, ensureTargetInView } from './tooltip-position-manager';
import type { GistMessage } from '../types';

vi.mock('../utilities/log', () => ({ log: vi.fn() }));
Expand Down Expand Up @@ -65,6 +65,7 @@ vi.mock('../utilities/message-utils', () => ({
}));
vi.mock('./tooltip-position-manager', () => ({
positionTooltip: vi.fn(),
ensureTargetInView: vi.fn(() => Promise.resolve(true)),
}));

describe('message-component-manager', () => {
Expand Down Expand Up @@ -192,7 +193,7 @@ describe('message-component-manager', () => {
expect(iframe?.onload).toBeTypeOf('function');
});

it('cleans up existing position listeners before re-creating the tooltip', () => {
it('cleans up existing position listeners before re-creating the tooltip', async () => {
const mockCleanup = vi.fn();
vi.mocked(positionTooltip).mockReturnValue({ cleanup: mockCleanup, reposition: vi.fn() });

Expand Down Expand Up @@ -222,7 +223,7 @@ describe('message-component-manager', () => {
};

loadTooltipComponent('https://view.example.com/index.html', message, baseOptions);
showTooltipComponent(message);
await showTooltipComponent(message);

expect(mockCleanup).not.toHaveBeenCalled();

Expand All @@ -249,7 +250,7 @@ describe('message-component-manager', () => {
return wrapper;
}

it('adds gist-visible class to the tooltip container and returns true when positioned', () => {
it('adds gist-visible class to the tooltip container and returns true when positioned', async () => {
vi.mocked(positionTooltip).mockReturnValue({ cleanup: vi.fn(), reposition: vi.fn() });
setupTooltipWrapper('inst-1');
const message: GistMessage = {
Expand All @@ -258,14 +259,14 @@ describe('message-component-manager', () => {
properties: { gist: { elementId: '#target-el' } },
};

const result = showTooltipComponent(message);
const result = await showTooltipComponent(message);

expect(result).toBe(true);
const container = document.querySelector('.gist-tooltip-container');
expect(container?.classList.contains('gist-visible')).toBe(true);
});

it('calls positionTooltip with the wrapper, selector, and position', () => {
it('calls positionTooltip with the wrapper, selector, and position', async () => {
setupTooltipWrapper('inst-1');
vi.mocked(resolveMessageProperties).mockReturnValue({
isEmbedded: false,
Expand All @@ -292,13 +293,13 @@ describe('message-component-manager', () => {
properties: { gist: { elementId: '#target-el', tooltipPosition: 'top' } },
};

showTooltipComponent(message);
await showTooltipComponent(message);

const tooltipElement = document.querySelector('.gist-tooltip-outer');
expect(positionTooltip).toHaveBeenCalledWith(tooltipElement, '#target-el', 'top');
});

it('defaults tooltip position to bottom when not specified', () => {
it('defaults tooltip position to bottom when not specified', async () => {
setupTooltipWrapper('inst-1');
vi.mocked(resolveMessageProperties).mockReturnValue({
isEmbedded: false,
Expand All @@ -325,12 +326,12 @@ describe('message-component-manager', () => {
properties: { gist: { elementId: '#target-el' } },
};

showTooltipComponent(message);
await showTooltipComponent(message);

expect(positionTooltip).toHaveBeenCalledWith(expect.any(HTMLElement), '#target-el', 'bottom');
});

it('returns false when positionTooltip returns null (target not found)', () => {
it('returns false when positionTooltip returns null (target not found)', async () => {
setupTooltipWrapper('inst-1');
vi.mocked(positionTooltip).mockReturnValue(null);

Expand All @@ -340,14 +341,14 @@ describe('message-component-manager', () => {
properties: { gist: { elementId: '#target-el' } },
};

const result = showTooltipComponent(message);
const result = await showTooltipComponent(message);

expect(result).toBe(false);
const container = document.querySelector('.gist-tooltip-container');
expect(container?.classList.contains('gist-visible')).toBe(false);
});

it('returns false and cleans up when tooltip container element is missing', () => {
it('returns false and cleans up when tooltip container element is missing', async () => {
const mockCleanup = vi.fn();
vi.mocked(positionTooltip).mockReturnValue({ cleanup: mockCleanup, reposition: vi.fn() });

Expand All @@ -364,14 +365,14 @@ describe('message-component-manager', () => {
properties: { gist: { elementId: '#target-el' } },
};

const result = showTooltipComponent(message);
const result = await showTooltipComponent(message);

expect(result).toBe(false);
expect(mockCleanup).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith('Tooltip container not found for instance inst-1');
});

it('returns false and cleans up when tooltip is hidden via display:none (no viewport fit)', () => {
it('returns false and cleans up when tooltip is hidden via display:none (no viewport fit)', async () => {
setupTooltipWrapper('inst-1');
const mockCleanup = vi.fn();

Expand All @@ -386,36 +387,36 @@ describe('message-component-manager', () => {
properties: { gist: { elementId: '#target-el' } },
};

const result = showTooltipComponent(message);
const result = await showTooltipComponent(message);

expect(result).toBe(false);
expect(mockCleanup).toHaveBeenCalled();
const container = document.querySelector('.gist-tooltip-container');
expect(container?.classList.contains('gist-visible')).toBe(false);
});

it('logs and returns false when wrapper is not found', () => {
it('logs and returns false when wrapper is not found', async () => {
const message: GistMessage = {
messageId: 'msg-1',
instanceId: 'inst-1',
properties: { gist: { elementId: '#target-el' } },
};

const result = showTooltipComponent(message);
const result = await showTooltipComponent(message);

expect(result).toBe(false);
expect(log).toHaveBeenCalledWith('Tooltip wrapper not found for instance inst-1');
expect(positionTooltip).not.toHaveBeenCalled();
});

it('logs and returns false when no target selector is provided', () => {
it('logs and returns false when no target selector is provided', async () => {
setupTooltipWrapper('inst-1');
const message: GistMessage = {
messageId: 'msg-1',
instanceId: 'inst-1',
};

const result = showTooltipComponent(message);
const result = await showTooltipComponent(message);

expect(result).toBe(false);
expect(log).toHaveBeenCalledWith('No target selector for tooltip inst-1');
Expand All @@ -424,6 +425,39 @@ describe('message-component-manager', () => {
const container = document.querySelector('.gist-tooltip-container');
expect(container?.classList.contains('gist-visible')).toBe(false);
});

it('returns false without calling positionTooltip when ensureTargetInView returns false', async () => {
vi.mocked(ensureTargetInView).mockResolvedValue(false);
setupTooltipWrapper('inst-1');
const message: GistMessage = {
messageId: 'msg-1',
instanceId: 'inst-1',
properties: { gist: { elementId: '#target-el' } },
};

const result = await showTooltipComponent(message);

expect(result).toBe(false);
expect(ensureTargetInView).toHaveBeenCalled();
expect(positionTooltip).not.toHaveBeenCalled();
});

it('proceeds to positionTooltip when ensureTargetInView returns true', async () => {
vi.mocked(ensureTargetInView).mockResolvedValue(true);
vi.mocked(positionTooltip).mockReturnValue({ cleanup: vi.fn(), reposition: vi.fn() });
setupTooltipWrapper('inst-1');
const message: GistMessage = {
messageId: 'msg-1',
instanceId: 'inst-1',
properties: { gist: { elementId: '#target-el' } },
};

const result = await showTooltipComponent(message);

expect(result).toBe(true);
expect(ensureTargetInView).toHaveBeenCalled();
expect(positionTooltip).toHaveBeenCalled();
});
});

describe('hideTooltipComponent', () => {
Expand All @@ -437,7 +471,7 @@ describe('message-component-manager', () => {
expect(document.getElementById('gist-tooltip-inst-1')).toBeNull();
});

it('calls the position cleanup function when one exists', () => {
it('calls the position cleanup function when one exists', async () => {
const wrapper = document.createElement('div');
wrapper.id = 'gist-tooltip-inst-1';
const tooltip = document.createElement('div');
Expand Down Expand Up @@ -479,7 +513,7 @@ describe('message-component-manager', () => {
properties: { gist: { elementId: '#target-el' } },
};

showTooltipComponent(message);
await showTooltipComponent(message);
hideTooltipComponent(message);

expect(mockCleanup).toHaveBeenCalled();
Expand Down Expand Up @@ -510,7 +544,7 @@ describe('message-component-manager', () => {
return wrapper;
}

it('calls cleanup on all tracked tooltip handles', () => {
it('calls cleanup on all tracked tooltip handles', async () => {
const cleanup1 = vi.fn();
const cleanup2 = vi.fn();
vi.mocked(positionTooltip)
Expand All @@ -520,12 +554,12 @@ describe('message-component-manager', () => {
setupTooltipWrapper('inst-1');
setupTooltipWrapper('inst-2');

showTooltipComponent({
await showTooltipComponent({
messageId: 'msg-1',
instanceId: 'inst-1',
properties: { gist: { elementId: '#target-1' } },
});
showTooltipComponent({
await showTooltipComponent({
messageId: 'msg-2',
instanceId: 'inst-2',
properties: { gist: { elementId: '#target-2' } },
Expand Down Expand Up @@ -564,12 +598,12 @@ describe('message-component-manager', () => {
expect(() => clearAllTooltipHandles()).not.toThrow();
});

it('clears the map so subsequent hideTooltipComponent does not double-cleanup', () => {
it('clears the map so subsequent hideTooltipComponent does not double-cleanup', async () => {
const mockCleanup = vi.fn();
vi.mocked(positionTooltip).mockReturnValue({ cleanup: mockCleanup, reposition: vi.fn() });

setupTooltipWrapper('inst-1');
showTooltipComponent({
await showTooltipComponent({
messageId: 'msg-1',
instanceId: 'inst-1',
properties: { gist: { elementId: '#target-1' } },
Expand Down
12 changes: 11 additions & 1 deletion src/managers/message-component-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { positions } from './page-component-manager';
import { wideOverlayPositions } from '../utilities/message-utils';
import {
positionTooltip,
ensureTargetInView,
type TooltipPosition,
type TooltipHandle,
} from './tooltip-position-manager';
Expand Down Expand Up @@ -251,7 +252,7 @@ export function loadTooltipComponent(
attachIframeLoadEvent(messageElementId, options, stepName);
}

export function showTooltipComponent(message: GistMessage): boolean {
export async function showTooltipComponent(message: GistMessage): Promise<boolean> {
const instanceId = message.instanceId ?? '';
const messageProperties = resolveMessageProperties(message);
const wrapperId = `gist-tooltip-${instanceId}`;
Expand Down Expand Up @@ -281,6 +282,15 @@ export function showTooltipComponent(message: GistMessage): boolean {
}

const position = (messageProperties.tooltipPosition || 'bottom') as TooltipPosition;

const targetReady = await ensureTargetInView(tooltipElement, selector, position);
if (!targetReady) {
log(
`Tooltip for instance ${instanceId} skipped: target "${selector}" is off-screen and cannot be scrolled into a valid position`
);
return false;
}

const handle = positionTooltip(tooltipElement, selector, position);
if (handle) {
const isVisible = tooltipElement.style.display !== 'none';
Expand Down
4 changes: 2 additions & 2 deletions src/managers/message-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ vi.mock('./message-component-manager', () => ({
changeOverlayTitle: vi.fn(),
sendDisplaySettingsToIframe: vi.fn(),
loadTooltipComponent: vi.fn(),
showTooltipComponent: vi.fn(() => true),
showTooltipComponent: vi.fn(() => Promise.resolve(true)),
hideTooltipComponent: vi.fn(),
}));
vi.mock('./gist-properties-manager', () => ({
Expand Down Expand Up @@ -626,7 +626,7 @@ describe('message-manager', () => {
const mocks = await import('./message-component-manager');

vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#target-btn'));
vi.mocked(mocks.showTooltipComponent).mockReturnValue(false);
vi.mocked(mocks.showTooltipComponent).mockResolvedValue(false);
addTargetElement('#target-btn');

const message: GistMessage = {
Expand Down
2 changes: 1 addition & 1 deletion src/managers/message-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ async function handleGistEvents(e: MessageEvent): Promise<void> {
resetTooltipState(currentMessage);
break;
}
const tooltipVisible = showTooltipComponent(currentMessage);
const tooltipVisible = await showTooltipComponent(currentMessage);
if (!tooltipVisible) {
log(
`Tooltip positioning failed for "${targetSelector}", emitting error and cleaning up`
Expand Down
Loading
Loading