diff --git a/examples/index.html b/examples/index.html index 813a9d0..ec6cf5a 100644 --- a/examples/index.html +++ b/examples/index.html @@ -101,6 +101,15 @@

Programmatic Dismiss

+
+

Auto-Scroll Into View

+

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.

+
+ + +
+
+

Tooltip Event Log

@@ -109,6 +118,14 @@

Tooltip Event Log

+
+
+ +

This element starts below the fold. The SDK scrolled here because it predicted a valid tooltip placement.

+
+
⚙️ Configuration Override & Debugging diff --git a/src/managers/message-component-manager.test.ts b/src/managers/message-component-manager.test.ts index 61259c4..383f148 100644 --- a/src/managers/message-component-manager.test.ts +++ b/src/managers/message-component-manager.test.ts @@ -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() })); @@ -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', () => { @@ -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() }); @@ -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(); @@ -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 = { @@ -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, @@ -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, @@ -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); @@ -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() }); @@ -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(); @@ -386,7 +387,7 @@ 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(); @@ -394,28 +395,28 @@ describe('message-component-manager', () => { 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'); @@ -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', () => { @@ -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'); @@ -479,7 +513,7 @@ describe('message-component-manager', () => { properties: { gist: { elementId: '#target-el' } }, }; - showTooltipComponent(message); + await showTooltipComponent(message); hideTooltipComponent(message); expect(mockCleanup).toHaveBeenCalled(); @@ -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) @@ -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' } }, @@ -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' } }, diff --git a/src/managers/message-component-manager.ts b/src/managers/message-component-manager.ts index 3622820..f0c55b9 100644 --- a/src/managers/message-component-manager.ts +++ b/src/managers/message-component-manager.ts @@ -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'; @@ -251,7 +252,7 @@ export function loadTooltipComponent( attachIframeLoadEvent(messageElementId, options, stepName); } -export function showTooltipComponent(message: GistMessage): boolean { +export async function showTooltipComponent(message: GistMessage): Promise { const instanceId = message.instanceId ?? ''; const messageProperties = resolveMessageProperties(message); const wrapperId = `gist-tooltip-${instanceId}`; @@ -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'; diff --git a/src/managers/message-manager.test.ts b/src/managers/message-manager.test.ts index 04314e2..953432b 100644 --- a/src/managers/message-manager.test.ts +++ b/src/managers/message-manager.test.ts @@ -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', () => ({ @@ -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 = { diff --git a/src/managers/message-manager.ts b/src/managers/message-manager.ts index 3542de9..0dc392b 100644 --- a/src/managers/message-manager.ts +++ b/src/managers/message-manager.ts @@ -393,7 +393,7 @@ async function handleGistEvents(e: MessageEvent): Promise { 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` diff --git a/src/managers/tooltip-position-manager.test.ts b/src/managers/tooltip-position-manager.test.ts index a91e29f..e76737f 100644 --- a/src/managers/tooltip-position-manager.test.ts +++ b/src/managers/tooltip-position-manager.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { findTargetElement, positionTooltip, type TooltipHandle } from './tooltip-position-manager'; +import { + findTargetElement, + positionTooltip, + canTooltipFitInViewport, + ensureTargetInView, + type TooltipHandle, +} from './tooltip-position-manager'; import { log } from '../utilities/log'; vi.mock('../utilities/log', () => ({ log: vi.fn() })); @@ -500,4 +506,206 @@ describe('tooltip-position-manager', () => { }); }); }); + + describe('canTooltipFitInViewport', () => { + it('returns true when tooltip fits around a centered target', () => { + createTarget({ top: 400, bottom: 440, left: 500, right: 580, width: 80, height: 40 }); + const tooltip = createTooltip({ width: 120, height: 50 }); + expect(canTooltipFitInViewport(tooltip, '#target', 'bottom')).toBe(true); + }); + + it('returns false when target element is not found', () => { + const tooltip = createTooltip({ width: 120, height: 50 }); + expect(canTooltipFitInViewport(tooltip, '#missing', 'bottom')).toBe(false); + }); + + it('returns false when tooltip is too large for the viewport', () => { + Object.defineProperty(window, 'innerWidth', { value: 100, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: 100, configurable: true }); + createTarget({ top: 30, bottom: 70, left: 30, right: 70, width: 40, height: 40 }); + const tooltip = createTooltip({ width: 200, height: 200 }); + expect(canTooltipFitInViewport(tooltip, '#target', 'bottom')).toBe(false); + }); + }); + + describe('ensureTargetInView', () => { + it('resolves true immediately when target is already visible', async () => { + createTarget({ top: 100, bottom: 140, left: 200, right: 280, width: 80, height: 40 }); + const tooltip = createTooltip({ width: 120, height: 50 }); + const result = await ensureTargetInView(tooltip, '#target', 'bottom'); + expect(result).toBe(true); + }); + + it('returns false when target element is not found', async () => { + const tooltip = createTooltip({ width: 120, height: 50 }); + const result = await ensureTargetInView(tooltip, '#missing', 'bottom'); + expect(result).toBe(false); + }); + + it('smooth-scrolls target into view when offscreen and preflight passes', async () => { + const target = createTarget({ + top: -200, + bottom: -160, + left: 200, + right: 280, + width: 80, + height: 40, + }); + const tooltip = createTooltip({ width: 120, height: 50 }); + + target.scrollIntoView = vi.fn(() => { + (target.getBoundingClientRect as ReturnType).mockReturnValue({ + top: 300, + bottom: 340, + left: 200, + right: 280, + width: 80, + height: 40, + x: 200, + y: 300, + toJSON: () => ({}), + }); + }); + + const result = await ensureTargetInView(tooltip, '#target', 'bottom'); + expect(target.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + expect(result).toBe(true); + }); + + it('scrolls target inside a scrollable ancestor into view', async () => { + const scrollContainer = document.createElement('div'); + scrollContainer.id = 'scroll-container'; + Object.defineProperty(scrollContainer, 'style', { + value: { overflow: 'auto', overflowX: '', overflowY: '' }, + writable: true, + }); + document.body.appendChild(scrollContainer); + + const target = document.createElement('div'); + target.id = 'nested-target'; + scrollContainer.appendChild(target); + + const clippedRect = { + top: -100, + bottom: -60, + left: 200, + right: 280, + width: 80, + height: 40, + x: 200, + y: -100, + toJSON: () => ({}), + } as DOMRect; + target.getBoundingClientRect = vi.fn(() => clippedRect); + + scrollContainer.getBoundingClientRect = vi.fn( + () => + ({ + top: 50, + bottom: 350, + left: 100, + right: 500, + width: 400, + height: 300, + x: 100, + y: 50, + toJSON: () => ({}), + }) as DOMRect + ); + + const tooltip = createTooltip({ width: 120, height: 50 }); + target.scrollIntoView = vi.fn(() => { + (target.getBoundingClientRect as ReturnType).mockReturnValue({ + top: 180, + bottom: 220, + left: 200, + right: 280, + width: 80, + height: 40, + x: 200, + y: 180, + toJSON: () => ({}), + }); + }); + + const result = await ensureTargetInView(tooltip, '#nested-target', 'right'); + expect(target.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + expect(result).toBe(true); + }); + + it('waits for horizontal scroll to settle before resolving', async () => { + const target = createTarget({ + top: -200, + bottom: -160, + left: -300, + right: -220, + width: 80, + height: 40, + }); + const tooltip = createTooltip({ width: 120, height: 50 }); + + let callCount = 0; + target.scrollIntoView = vi.fn(() => { + callCount = 0; + (target.getBoundingClientRect as ReturnType).mockImplementation(() => { + callCount++; + if (callCount <= 2) { + return { + top: 300, + bottom: 340, + left: 200 + (3 - callCount) * 40, + right: 280 + (3 - callCount) * 40, + width: 80, + height: 40, + x: 200 + (3 - callCount) * 40, + y: 300, + toJSON: () => ({}), + }; + } + return { + top: 300, + bottom: 340, + left: 200, + right: 280, + width: 80, + height: 40, + x: 200, + y: 300, + toJSON: () => ({}), + }; + }); + }); + + const result = await ensureTargetInView(tooltip, '#target', 'bottom'); + expect(target.scrollIntoView).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('does not scroll when preflight predicts tooltip will not fit', async () => { + Object.defineProperty(window, 'innerWidth', { value: 100, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: 100, configurable: true }); + const target = createTarget({ + top: -200, + bottom: -160, + left: 200, + right: 280, + width: 80, + height: 40, + }); + const tooltip = createTooltip({ width: 200, height: 200 }); + target.scrollIntoView = vi.fn(); + + const result = await ensureTargetInView(tooltip, '#target', 'bottom'); + expect(target.scrollIntoView).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + }); }); diff --git a/src/managers/tooltip-position-manager.ts b/src/managers/tooltip-position-manager.ts index 796343d..020e743 100644 --- a/src/managers/tooltip-position-manager.ts +++ b/src/managers/tooltip-position-manager.ts @@ -244,6 +244,118 @@ export interface TooltipHandle { reposition: () => void; } +/** + * Predicts whether the tooltip can be positioned after the target is scrolled + * into view. Returns true only when the target exists in the DOM and at least + * one placement (preferred + fallbacks) would fit the viewport assuming the + * target occupies a centered viewport position after scrollIntoView. + */ +export function canTooltipFitInViewport( + tooltipElement: HTMLElement, + targetSelector: string, + position: TooltipPosition +): boolean { + const targetElement = findTargetElement(targetSelector); + if (!targetElement) return false; + + const targetRect = targetElement.getBoundingClientRect(); + const vpW = window.innerWidth; + const vpH = window.innerHeight; + + const simulatedTargetRect = new DOMRect( + Math.max(0, (vpW - targetRect.width) / 2), + Math.max(0, (vpH - targetRect.height) / 2), + targetRect.width, + targetRect.height + ); + + tooltipElement.style.display = ''; + const tooltipRect = tooltipElement.getBoundingClientRect(); + + return findBestPosition(tooltipRect, simulatedTargetRect, position) !== null; +} + +const SCROLL_POLL_INTERVAL_MS = 50; +const SCROLL_SETTLE_TIMEOUT_MS = 1000; + +function waitForScrollSettle(targetElement: Element): Promise { + return new Promise((resolve) => { + let lastRect = targetElement.getBoundingClientRect(); + let stableFrames = 0; + const start = Date.now(); + + function check(): void { + const currentRect = targetElement.getBoundingClientRect(); + if ( + Math.abs(currentRect.top - lastRect.top) < 1 && + Math.abs(currentRect.left - lastRect.left) < 1 + ) { + stableFrames++; + } else { + stableFrames = 0; + } + lastRect = currentRect; + + if (stableFrames >= 2 || Date.now() - start > SCROLL_SETTLE_TIMEOUT_MS) { + resolve(); + return; + } + setTimeout(check, SCROLL_POLL_INTERVAL_MS); + } + + setTimeout(check, SCROLL_POLL_INTERVAL_MS); + }); +} + +/** + * If the target is already visible, resolves immediately. + * Otherwise, if `canTooltipFitInViewport` predicts a valid placement, smoothly + * scrolls the target into view (including within nested scroll containers) and + * waits for the scroll to settle. Returns false without scrolling when the + * preflight fails. + */ +export async function ensureTargetInView( + tooltipElement: HTMLElement, + targetSelector: string, + position: TooltipPosition +): Promise { + const targetElement = findTargetElement(targetSelector); + if (!targetElement) return false; + + let scrollAncestors: Element[] = []; + try { + scrollAncestors = getScrollableAncestors(targetElement); + } catch { + // getComputedStyle may throw in test environments + } + + const targetRect = targetElement.getBoundingClientRect(); + if (isTargetVisible(targetRect, scrollAncestors)) { + return true; + } + + if (!canTooltipFitInViewport(tooltipElement, targetSelector, position)) { + log( + `Preflight failed: tooltip would not fit after scrolling target "${targetSelector}" into view` + ); + return false; + } + + targetElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + + await waitForScrollSettle(targetElement); + + const postScrollRect = targetElement.getBoundingClientRect(); + let postScrollAncestors: Element[] = []; + try { + postScrollAncestors = getScrollableAncestors(targetElement); + } catch { + // ignore + } + + return isTargetVisible(postScrollRect, postScrollAncestors); +} + export function positionTooltip( tooltipElement: HTMLElement, targetSelector: string,