diff --git a/examples/index.html b/examples/index.html index dec3c38..813a9d0 100644 --- a/examples/index.html +++ b/examples/index.html @@ -41,10 +41,74 @@

Gist for Web

Set Anonymous Custom Attribute Log In Log Out + Tooltip Demo ↓

More information can be found on our docs, if you have any question you can email us at support@gist.build

+
+

Tooltip Demo

+ +
+

Basic Positioning

+

Click a button to show a tooltip anchored in that direction. Each button is centered in a large area so the tooltip has room to appear in the requested position.

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+

Edge Detection & Auto-Flip

+

These targets sit near edges. The tooltip auto-flips or slides to stay fully visible.

+
+ + + + +
+
+ +
+

Scroll Container Support

+

Tooltips reposition when scrollable containers scroll. Scroll the list and click "Info" on any item.

+
+
Feature A
+
Feature B
+
Feature C
+
Feature D
+
Feature E
+
Feature F
+
Feature G
+
Feature H
+
+
+ +
+

Programmatic Dismiss

+
+ +
+
+ +
+

Tooltip Event Log

+
+
Tooltip events will appear here…
+
+
+
+
⚙️ Configuration Override & Debugging @@ -111,6 +175,7 @@

Active Messages & Display Settings

diff --git a/examples/styles.css b/examples/styles.css index 53f12dd..76a68b2 100644 --- a/examples/styles.css +++ b/examples/styles.css @@ -421,4 +421,175 @@ h1 { .form-actions .button { width: 100%; } -} \ No newline at end of file +} + +/* Tooltip Demo */ +.tooltip-demo { + margin: 24px 16px; + background: #fff; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0,0,0,.06); + padding: 28px; +} + +.tooltip-demo h2 { + font-size: 22px; + margin-bottom: 24px; + color: #264653; + border-bottom: 2px solid #e76f51; + padding-bottom: 8px; +} + +.tooltip-section { + margin-bottom: 24px; +} + +.tooltip-section h3 { + font-size: 16px; + margin-bottom: 8px; + color: #333; +} + +.tooltip-section p { + color: #666; + font-size: 14px; + line-height: 1.6; + margin-bottom: 12px; +} + +.tooltip-section code { + background: #f0f0f5; + padding: 2px 6px; + border-radius: 4px; + font-size: 13px; +} + +.tooltip-btn-row { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.tooltip-target { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + border: none; + cursor: pointer; +} + +.btn-blue { background: #3b82f6; color: #fff; } +.btn-green { background: #10b981; color: #fff; } +.btn-orange { background: #f59e0b; color: #fff; } +.btn-purple { background: #8b5cf6; color: #fff; } +.btn-pink { background: #ec4899; color: #fff; } + +.tooltip-position-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.tooltip-position-cell { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + background: #f8fafc; + border: 1px dashed #cbd5e1; + border-radius: 10px; +} + +.edge-demo-area { + position: relative; + min-height: 180px; + background: #f8fafc; + border: 1px dashed #cbd5e1; + border-radius: 10px; +} + +.edge-demo-area .tooltip-target { + position: absolute; +} + +.edge-demo-area .edge-tl { top: 8px; left: 8px; } +.edge-demo-area .edge-tr { top: 8px; right: 8px; } +.edge-demo-area .edge-bl { bottom: 8px; left: 8px; } +.edge-demo-area .edge-br { bottom: 8px; right: 8px; } + +.tooltip-scroll-container { + max-height: 200px; + overflow-y: auto; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 16px; +} + +.tooltip-scroll-container .scroll-item { + padding: 12px 16px; + border-bottom: 1px solid #f0f0f5; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; +} + +.tooltip-scroll-container .scroll-item:last-child { + border-bottom: none; +} + +.scroll-item-btn { + background: #e0e7ff; + color: #4f46e5; + border: none; + padding: 4px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.tooltip-event-log { + background: #1e293b; + border-radius: 10px; + padding: 16px; + min-height: 80px; + max-height: 200px; + overflow-y: auto; + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 12px; +} + +.tooltip-event-log .log-entry { + color: #94a3b8; + padding: 3px 0; + border-bottom: 1px solid #2d3748; +} + +.tooltip-event-log .log-entry:last-child { + border-bottom: none; +} + +.tooltip-event-log .log-entry .tag { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + margin-right: 6px; +} + +.tag-shown { background: #065f46; color: #6ee7b7; } +.tag-action { background: #1e3a5f; color: #7dd3fc; } +.tag-error { background: #7f1d1d; color: #fca5a5; } +.tag-dismiss { background: #78350f; color: #fcd34d; } + +.tooltip-event-log .no-events { + color: #475569; + font-style: italic; +} diff --git a/src/gist.test.ts b/src/gist.test.ts index 661bf17..b436b98 100644 --- a/src/gist.test.ts +++ b/src/gist.test.ts @@ -38,6 +38,7 @@ vi.mock('./utilities/message-utils', () => ({ })); vi.mock('./managers/message-component-manager', () => ({ sendDisplaySettingsToIframe: vi.fn(), + clearAllTooltipHandles: vi.fn(), })); vi.mock('./managers/locale-manager', () => ({ setUserLocale: vi.fn(), @@ -73,7 +74,10 @@ import { logBroadcastDismissedLocally, } from './managers/message-manager'; import { fetchMessageByInstanceId } from './utilities/message-utils'; -import { sendDisplaySettingsToIframe } from './managers/message-component-manager'; +import { + sendDisplaySettingsToIframe, + clearAllTooltipHandles, +} from './managers/message-component-manager'; import { setUserLocale } from './managers/locale-manager'; import { setCustomAttribute, @@ -217,6 +221,11 @@ describe('Gist', () => { expect(Gist.currentMessages).toEqual([]); }); + it('clears all tooltip handles during setup', async () => { + await Gist.setup(baseConfig()); + expect(clearAllTooltipHandles).toHaveBeenCalled(); + }); + it('sets isDocumentVisible to true', async () => { await Gist.setup(baseConfig()); expect(Gist.isDocumentVisible).toBe(true); diff --git a/src/gist.ts b/src/gist.ts index 4eaacdf..23e1921 100644 --- a/src/gist.ts +++ b/src/gist.ts @@ -16,7 +16,10 @@ import { logBroadcastDismissedLocally, } from './managers/message-manager'; import { fetchMessageByInstanceId } from './utilities/message-utils'; -import { sendDisplaySettingsToIframe } from './managers/message-component-manager'; +import { + sendDisplaySettingsToIframe, + clearAllTooltipHandles, +} from './managers/message-component-manager'; import { setUserLocale } from './managers/locale-manager'; import { setCustomAttribute, @@ -57,6 +60,7 @@ export default class Gist { experiments: config.experiments ?? false, }; this.currentMessages = []; + clearAllTooltipHandles(); this.overlayInstanceId = null; this.currentRoute = null; this.isDocumentVisible = true; diff --git a/src/managers/gist-properties-manager.ts b/src/managers/gist-properties-manager.ts index 9ed8b5a..eec8667 100644 --- a/src/managers/gist-properties-manager.ts +++ b/src/managers/gist-properties-manager.ts @@ -26,7 +26,7 @@ export function resolveMessageProperties(message: GistMessage): ResolvedMessageP if (!gist) return defaults; return { - isEmbedded: !!gist.elementId, + isEmbedded: !!gist.elementId && !gist.tooltipPosition, elementId: gist.elementId || '', hasRouteRule: !!gist.routeRuleWeb, routeRule: gist.routeRuleWeb || '', diff --git a/src/managers/message-component-manager.test.ts b/src/managers/message-component-manager.test.ts index b148c9f..61259c4 100644 --- a/src/managers/message-component-manager.test.ts +++ b/src/managers/message-component-manager.test.ts @@ -9,6 +9,7 @@ import { loadTooltipComponent, showTooltipComponent, hideTooltipComponent, + clearAllTooltipHandles, } from './message-component-manager'; import { log } from '../utilities/log'; import { resolveMessageProperties } from './gist-properties-manager'; @@ -52,8 +53,8 @@ vi.mock('../templates/message', () => ({ })); vi.mock('../templates/tooltip', () => ({ tooltipHTMLTemplate: vi.fn( - (_id: string, _props: unknown, url: string) => - `
` + (_id: string, _props: unknown, url: string, _wrapperId?: string) => + `
` ), })); vi.mock('./page-component-manager', () => ({ @@ -193,7 +194,7 @@ describe('message-component-manager', () => { it('cleans up existing position listeners before re-creating the tooltip', () => { const mockCleanup = vi.fn(); - vi.mocked(positionTooltip).mockReturnValue(mockCleanup); + vi.mocked(positionTooltip).mockReturnValue({ cleanup: mockCleanup, reposition: vi.fn() }); vi.mocked(resolveMessageProperties).mockReturnValue({ isEmbedded: false, @@ -236,9 +237,9 @@ describe('message-component-manager', () => { const wrapper = document.createElement('div'); wrapper.id = `gist-tooltip-${instanceId}`; const tooltip = document.createElement('div'); - tooltip.id = 'gist-tooltip'; + tooltip.className = 'gist-tooltip-outer'; const container = document.createElement('div'); - container.id = 'gist-tooltip-container'; + container.className = 'gist-tooltip-container'; const iframe = document.createElement('iframe'); iframe.className = 'gist-tooltip-frame'; container.appendChild(iframe); @@ -248,7 +249,8 @@ describe('message-component-manager', () => { return wrapper; } - it('adds gist-visible class to the tooltip iframe', () => { + it('adds gist-visible class to the tooltip container and returns true when positioned', () => { + vi.mocked(positionTooltip).mockReturnValue({ cleanup: vi.fn(), reposition: vi.fn() }); setupTooltipWrapper('inst-1'); const message: GistMessage = { messageId: 'msg-1', @@ -256,10 +258,11 @@ describe('message-component-manager', () => { properties: { gist: { elementId: '#target-el' } }, }; - showTooltipComponent(message); + const result = showTooltipComponent(message); - const iframe = document.querySelector('.gist-tooltip-frame'); - expect(iframe?.classList.contains('gist-visible')).toBe(true); + 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', () => { @@ -291,7 +294,7 @@ describe('message-component-manager', () => { showTooltipComponent(message); - const tooltipElement = document.getElementById('gist-tooltip'); + const tooltipElement = document.querySelector('.gist-tooltip-outer'); expect(positionTooltip).toHaveBeenCalledWith(tooltipElement, '#target-el', 'top'); }); @@ -327,33 +330,99 @@ describe('message-component-manager', () => { expect(positionTooltip).toHaveBeenCalledWith(expect.any(HTMLElement), '#target-el', 'bottom'); }); - it('logs and returns early when wrapper is not found', () => { + it('returns false when positionTooltip returns null (target not found)', () => { + setupTooltipWrapper('inst-1'); + vi.mocked(positionTooltip).mockReturnValue(null); + const message: GistMessage = { messageId: 'msg-1', instanceId: 'inst-1', properties: { gist: { elementId: '#target-el' } }, }; - showTooltipComponent(message); + const result = 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', () => { + const mockCleanup = vi.fn(); + vi.mocked(positionTooltip).mockReturnValue({ cleanup: mockCleanup, reposition: vi.fn() }); + + const wrapper = document.createElement('div'); + wrapper.id = 'gist-tooltip-inst-1'; + const tooltip = document.createElement('div'); + tooltip.className = 'gist-tooltip-outer'; + wrapper.appendChild(tooltip); + document.body.appendChild(wrapper); + + const message: GistMessage = { + messageId: 'msg-1', + instanceId: 'inst-1', + properties: { gist: { elementId: '#target-el' } }, + }; + + const result = 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)', () => { + setupTooltipWrapper('inst-1'); + const mockCleanup = vi.fn(); + + vi.mocked(positionTooltip).mockImplementation((el) => { + (el as HTMLElement).style.display = 'none'; + return { cleanup: mockCleanup, reposition: vi.fn() }; + }); + + const message: GistMessage = { + messageId: 'msg-1', + instanceId: 'inst-1', + properties: { gist: { elementId: '#target-el' } }, + }; + + const result = 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', () => { + const message: GistMessage = { + messageId: 'msg-1', + instanceId: 'inst-1', + properties: { gist: { elementId: '#target-el' } }, + }; + + const result = 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 early when no target selector is provided', () => { + it('logs and returns false when no target selector is provided', () => { setupTooltipWrapper('inst-1'); const message: GistMessage = { messageId: 'msg-1', instanceId: 'inst-1', }; - showTooltipComponent(message); + const result = showTooltipComponent(message); + expect(result).toBe(false); expect(log).toHaveBeenCalledWith('No target selector for tooltip inst-1'); expect(positionTooltip).not.toHaveBeenCalled(); - const iframe = document.querySelector('.gist-tooltip-frame'); - expect(iframe?.classList.contains('gist-visible')).toBe(false); + const container = document.querySelector('.gist-tooltip-container'); + expect(container?.classList.contains('gist-visible')).toBe(false); }); }); @@ -372,9 +441,9 @@ describe('message-component-manager', () => { const wrapper = document.createElement('div'); wrapper.id = 'gist-tooltip-inst-1'; const tooltip = document.createElement('div'); - tooltip.id = 'gist-tooltip'; + tooltip.className = 'gist-tooltip-outer'; const container = document.createElement('div'); - container.id = 'gist-tooltip-container'; + container.className = 'gist-tooltip-container'; const iframe = document.createElement('iframe'); iframe.className = 'gist-tooltip-frame'; container.appendChild(iframe); @@ -383,7 +452,7 @@ describe('message-component-manager', () => { document.body.appendChild(wrapper); const mockCleanup = vi.fn(); - vi.mocked(positionTooltip).mockReturnValue(mockCleanup); + vi.mocked(positionTooltip).mockReturnValue({ cleanup: mockCleanup, reposition: vi.fn() }); vi.mocked(resolveMessageProperties).mockReturnValue({ isEmbedded: false, @@ -423,4 +492,93 @@ describe('message-component-manager', () => { }).not.toThrow(); }); }); + + describe('clearAllTooltipHandles', () => { + function setupTooltipWrapper(instanceId: string): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.id = `gist-tooltip-${instanceId}`; + const tooltip = document.createElement('div'); + tooltip.className = 'gist-tooltip-outer'; + const container = document.createElement('div'); + container.className = 'gist-tooltip-container'; + const iframe = document.createElement('iframe'); + iframe.className = 'gist-tooltip-frame'; + container.appendChild(iframe); + tooltip.appendChild(container); + wrapper.appendChild(tooltip); + document.body.appendChild(wrapper); + return wrapper; + } + + it('calls cleanup on all tracked tooltip handles', () => { + const cleanup1 = vi.fn(); + const cleanup2 = vi.fn(); + vi.mocked(positionTooltip) + .mockReturnValueOnce({ cleanup: cleanup1, reposition: vi.fn() }) + .mockReturnValueOnce({ cleanup: cleanup2, reposition: vi.fn() }); + + setupTooltipWrapper('inst-1'); + setupTooltipWrapper('inst-2'); + + showTooltipComponent({ + messageId: 'msg-1', + instanceId: 'inst-1', + properties: { gist: { elementId: '#target-1' } }, + }); + showTooltipComponent({ + messageId: 'msg-2', + instanceId: 'inst-2', + properties: { gist: { elementId: '#target-2' } }, + }); + + clearAllTooltipHandles(); + + expect(cleanup1).toHaveBeenCalledTimes(1); + expect(cleanup2).toHaveBeenCalledTimes(1); + }); + + it('removes all tooltip wrapper DOM elements', () => { + setupTooltipWrapper('inst-1'); + setupTooltipWrapper('inst-2'); + + expect(document.getElementById('gist-tooltip-inst-1')).not.toBeNull(); + expect(document.getElementById('gist-tooltip-inst-2')).not.toBeNull(); + + clearAllTooltipHandles(); + + expect(document.getElementById('gist-tooltip-inst-1')).toBeNull(); + expect(document.getElementById('gist-tooltip-inst-2')).toBeNull(); + }); + + it('removes untracked tooltip wrappers that have no handle in the map', () => { + setupTooltipWrapper('orphan-1'); + + expect(document.getElementById('gist-tooltip-orphan-1')).not.toBeNull(); + + clearAllTooltipHandles(); + + expect(document.getElementById('gist-tooltip-orphan-1')).toBeNull(); + }); + + it('does not throw when no handles are tracked', () => { + expect(() => clearAllTooltipHandles()).not.toThrow(); + }); + + it('clears the map so subsequent hideTooltipComponent does not double-cleanup', () => { + const mockCleanup = vi.fn(); + vi.mocked(positionTooltip).mockReturnValue({ cleanup: mockCleanup, reposition: vi.fn() }); + + setupTooltipWrapper('inst-1'); + showTooltipComponent({ + messageId: 'msg-1', + instanceId: 'inst-1', + properties: { gist: { elementId: '#target-1' } }, + }); + + clearAllTooltipHandles(); + hideTooltipComponent({ messageId: 'msg-1', instanceId: 'inst-1' }); + + expect(mockCleanup).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/managers/message-component-manager.ts b/src/managers/message-component-manager.ts index c2c3c91..3622820 100644 --- a/src/managers/message-component-manager.ts +++ b/src/managers/message-component-manager.ts @@ -6,7 +6,11 @@ import { messageHTMLTemplate } from '../templates/message'; import { tooltipHTMLTemplate } from '../templates/tooltip'; import { positions } from './page-component-manager'; import { wideOverlayPositions } from '../utilities/message-utils'; -import { positionTooltip, type TooltipPosition } from './tooltip-position-manager'; +import { + positionTooltip, + type TooltipPosition, + type TooltipHandle, +} from './tooltip-position-manager'; import type { GistMessage, ResolvedMessageProperties } from '../types'; interface MessageOptions { @@ -216,7 +220,7 @@ export function removeOverlayComponent(): void { } } -const tooltipCleanupMap = new Map void>(); +const tooltipHandleMap = new Map(); export function loadTooltipComponent( url: string, @@ -228,71 +232,88 @@ export function loadTooltipComponent( const messageElementId = getMessageElementId(instanceId); const messageProperties = resolveMessageProperties(message); - const existingCleanup = tooltipCleanupMap.get(instanceId); - if (existingCleanup) { - existingCleanup(); - tooltipCleanupMap.delete(instanceId); + const existingHandle = tooltipHandleMap.get(instanceId); + if (existingHandle) { + existingHandle.cleanup(); + tooltipHandleMap.delete(instanceId); } document.querySelectorAll(`#gist-tooltip-${instanceId}`).forEach((el) => { el.parentNode?.removeChild(el); }); + const wrapperId = `gist-tooltip-${instanceId}`; const wrapper = document.createElement('div'); - wrapper.id = `gist-tooltip-${instanceId}`; - wrapper.innerHTML = tooltipHTMLTemplate(messageElementId, messageProperties, url); + wrapper.id = wrapperId; + wrapper.innerHTML = tooltipHTMLTemplate(messageElementId, messageProperties, url, wrapperId); document.body.appendChild(wrapper); attachIframeLoadEvent(messageElementId, options, stepName); } -export function showTooltipComponent(message: GistMessage): void { +export function showTooltipComponent(message: GistMessage): boolean { const instanceId = message.instanceId ?? ''; const messageProperties = resolveMessageProperties(message); const wrapperId = `gist-tooltip-${instanceId}`; const wrapper = safelyFetchElement(wrapperId); if (!wrapper) { log(`Tooltip wrapper not found for instance ${instanceId}`); - return; + return false; } - const selector = message.properties?.gist?.elementId as string | undefined; + const selector = + (message.properties?.gist?.elementId as string | undefined) || message.elementId || undefined; if (!selector) { log(`No target selector for tooltip ${instanceId}`); - return; + return false; } - const existingCleanup = tooltipCleanupMap.get(instanceId); - if (existingCleanup) { - existingCleanup(); - tooltipCleanupMap.delete(instanceId); + const existingHandle = tooltipHandleMap.get(instanceId); + if (existingHandle) { + existingHandle.cleanup(); + tooltipHandleMap.delete(instanceId); } - const tooltipElement = wrapper.querySelector('#gist-tooltip') as HTMLElement | null; + const tooltipElement = wrapper.querySelector('.gist-tooltip-outer') as HTMLElement | null; if (!tooltipElement) { log(`Tooltip inner element not found for instance ${instanceId}`); - return; + return false; } const position = (messageProperties.tooltipPosition || 'bottom') as TooltipPosition; - const cleanup = positionTooltip(tooltipElement, selector, position); - if (cleanup) { - tooltipCleanupMap.set(instanceId, cleanup); - } - - const iframe = wrapper.querySelector('.gist-tooltip-frame') as HTMLElement | null; - if (iframe) { - iframe.classList.add('gist-visible'); + const handle = positionTooltip(tooltipElement, selector, position); + if (handle) { + const isVisible = tooltipElement.style.display !== 'none'; + if (isVisible) { + const container = wrapper.querySelector('.gist-tooltip-container') as HTMLElement | null; + if (!container) { + handle.cleanup(); + log(`Tooltip container not found for instance ${instanceId}`); + return false; + } + tooltipHandleMap.set(instanceId, handle); + container.classList.add('gist-visible'); + return true; + } + handle.cleanup(); + log( + `Tooltip for instance ${instanceId} could not be positioned within the viewport, target "${selector}" may be off-screen` + ); + return false; } + log( + `Failed to position tooltip for instance ${instanceId}, target "${selector}" may not exist or no position fits the viewport` + ); + return false; } export function hideTooltipComponent(message: GistMessage): void { const instanceId = message.instanceId ?? ''; - const existingCleanup = tooltipCleanupMap.get(instanceId); - if (existingCleanup) { - existingCleanup(); - tooltipCleanupMap.delete(instanceId); + const existingHandle = tooltipHandleMap.get(instanceId); + if (existingHandle) { + existingHandle.cleanup(); + tooltipHandleMap.delete(instanceId); } const wrapperId = `gist-tooltip-${instanceId}`; @@ -302,6 +323,32 @@ export function hideTooltipComponent(message: GistMessage): void { } } +export function clearAllTooltipHandles(): void { + tooltipHandleMap.forEach((handle) => handle.cleanup()); + tooltipHandleMap.clear(); + + document.querySelectorAll('[id^="gist-tooltip-"]').forEach((el) => { + el.parentNode?.removeChild(el); + }); +} + +export function resizeTooltipComponent( + message: GistMessage, + size: { width: number; height: number } +): void { + const instanceId = message.instanceId ?? ''; + const iframeId = getMessageElementId(instanceId); + const iframe = document.getElementById(iframeId) as HTMLIFrameElement | null; + if (iframe && size.height > 0) { + iframe.style.height = `${size.height}px`; + + const handle = tooltipHandleMap.get(instanceId); + if (handle) { + handle.reposition(); + } + } +} + export function changeOverlayTitle(instanceId: string, title: string): void { const element = safelyFetchElement(getMessageElementId(instanceId)); if (element) { diff --git a/src/managers/message-manager.test.ts b/src/managers/message-manager.test.ts index 09da0f7..04314e2 100644 --- a/src/managers/message-manager.test.ts +++ b/src/managers/message-manager.test.ts @@ -50,9 +50,13 @@ vi.mock('./message-component-manager', () => ({ showEmbedComponent: vi.fn(), hideEmbedComponent: vi.fn(), resizeComponent: vi.fn(), + resizeTooltipComponent: vi.fn(), elementHasHeight: vi.fn(() => false), changeOverlayTitle: vi.fn(), sendDisplaySettingsToIframe: vi.fn(), + loadTooltipComponent: vi.fn(), + showTooltipComponent: vi.fn(() => true), + hideTooltipComponent: vi.fn(), })); vi.mock('./gist-properties-manager', () => ({ resolveMessageProperties: vi.fn(() => ({ @@ -62,6 +66,9 @@ vi.mock('./gist-properties-manager', () => ({ routeRule: '', position: '', hasPosition: false, + tooltipPosition: '', + hasTooltipPosition: false, + tooltipArrowColor: '#fff', shouldScale: false, campaignId: null, messageWidth: 414, @@ -101,6 +108,7 @@ vi.mock('../utilities/message-utils', () => ({ updateMessageByInstanceId: vi.fn(), hasDisplayChanged: vi.fn(() => false), applyDisplaySettings: vi.fn(), + getCurrentDisplayType: vi.fn(() => 'modal'), })); vi.mock('./preview-bar-manager', () => ({ updatePreviewBarMessage: vi.fn(), @@ -328,4 +336,426 @@ describe('message-manager', () => { expect(updatePreviewBarStep).not.toHaveBeenCalled(); }); }); + + describe('tooltip flow', () => { + function addTargetElement(selector: string): HTMLElement { + const id = selector.replace(/^#/, ''); + const el = document.createElement('div'); + el.id = id; + document.body.appendChild(el); + return el; + } + + function tooltipProperties(elementId: string, tooltipPosition = 'bottom') { + return { + isEmbedded: false, + elementId, + hasRouteRule: false, + routeRule: '', + position: '', + hasPosition: false, + tooltipPosition, + hasTooltipPosition: true, + tooltipArrowColor: '#fff', + shouldScale: false, + campaignId: null, + messageWidth: 280, + overlayColor: '#00000033', + persistent: false, + exitClick: false, + hasCustomWidth: false, + }; + } + + beforeEach(async () => { + document.body.innerHTML = ''; + const { isQueueIdAlreadyShowing, getCurrentDisplayType } = + await import('../utilities/message-utils'); + vi.mocked(isQueueIdAlreadyShowing).mockReturnValue(false); + vi.mocked(getCurrentDisplayType).mockReturnValue('tooltip'); + }); + + it('routes to tooltip flow when message has tooltipPosition', async () => { + const { loadTooltipComponent } = await import('./message-component-manager'); + const { resolveMessageProperties } = await import('./gist-properties-manager'); + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#target-btn')); + + addTargetElement('#target-btn'); + + const message: GistMessage = { + messageId: 'tooltip-1', + tooltipPosition: 'bottom', + properties: { gist: { elementId: '#target-btn', tooltipPosition: 'bottom' } }, + }; + + const result = await showMessage(message); + + expect(result).toBe(message); + expect(message.instanceId).toBe('mock-uuid'); + expect(message.overlay).toBe(false); + expect(message.shouldScale).toBe(false); + expect(message.shouldResizeHeight).toBe(false); + expect(mockGist.currentMessages).toContain(message); + expect(mockGist.overlayInstanceId).toBeNull(); + expect(loadTooltipComponent).toHaveBeenCalled(); + }); + + it('detects tooltip from properties when not set on message directly', async () => { + const { loadTooltipComponent } = await import('./message-component-manager'); + const { resolveMessageProperties } = await import('./gist-properties-manager'); + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#target-btn')); + + addTargetElement('#target-btn'); + + const message: GistMessage = { + messageId: 'tooltip-2', + properties: { gist: { elementId: '#target-btn', tooltipPosition: 'top' } }, + }; + + const result = await showMessage(message); + + expect(result).toBe(message); + expect(message.tooltipPosition).toBe('bottom'); + expect(loadTooltipComponent).toHaveBeenCalled(); + }); + + it('copies elementId from properties when not on message', async () => { + const { resolveMessageProperties } = await import('./gist-properties-manager'); + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#target-btn')); + + addTargetElement('#target-btn'); + + const message: GistMessage = { + messageId: 'tooltip-3', + tooltipPosition: 'bottom', + properties: { gist: { elementId: '#target-btn', tooltipPosition: 'bottom' } }, + }; + + await showMessage(message); + + expect(message.elementId).toBe('#target-btn'); + }); + + it('returns null and emits error when no target selector', async () => { + const { resolveMessageProperties } = await import('./gist-properties-manager'); + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('')); + + const message: GistMessage = { + messageId: 'tooltip-4', + tooltipPosition: 'bottom', + }; + + const result = await showMessage(message); + + expect(result).toBeNull(); + expect(mockGist.messageError).toHaveBeenCalledWith(message); + expect(mockGist.currentMessages).toHaveLength(0); + }); + + it('returns null and emits error when target element not in DOM', async () => { + const { resolveMessageProperties } = await import('./gist-properties-manager'); + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#nonexistent')); + + const message: GistMessage = { + messageId: 'tooltip-5', + tooltipPosition: 'bottom', + properties: { gist: { elementId: '#nonexistent', tooltipPosition: 'bottom' } }, + }; + + const result = await showMessage(message); + + expect(result).toBeNull(); + expect(mockGist.messageError).toHaveBeenCalledWith(message); + }); + + it('allows tooltip when an overlay is already active', async () => { + const { loadTooltipComponent } = await import('./message-component-manager'); + const { resolveMessageProperties } = await import('./gist-properties-manager'); + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#target-btn')); + + addTargetElement('#target-btn'); + mockGist.overlayInstanceId = 'existing-overlay'; + + const message: GistMessage = { + messageId: 'tooltip-6', + tooltipPosition: 'bottom', + properties: { gist: { elementId: '#target-btn', tooltipPosition: 'bottom' } }, + }; + + const result = await showMessage(message); + + expect(result).toBe(message); + expect(loadTooltipComponent).toHaveBeenCalled(); + expect(mockGist.overlayInstanceId).toBe('existing-overlay'); + }); + + it('dismisses existing tooltip on same target before showing new one', async () => { + const { hideTooltipComponent } = await import('./message-component-manager'); + const { removeMessageByInstanceId } = await import('../utilities/message-utils'); + const { resolveMessageProperties } = await import('./gist-properties-manager'); + + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#target-btn')); + addTargetElement('#target-btn'); + + const existing: GistMessage = { + messageId: 'tooltip-old', + instanceId: 'old-inst', + tooltipPosition: 'top', + elementId: '#target-btn', + }; + mockGist.currentMessages = [existing]; + + const message: GistMessage = { + messageId: 'tooltip-new', + tooltipPosition: 'bottom', + properties: { gist: { elementId: '#target-btn', tooltipPosition: 'bottom' } }, + }; + + await showMessage(message); + + expect(mockGist.messageDismissed).toHaveBeenCalledWith(existing); + expect(hideTooltipComponent).toHaveBeenCalledWith(existing); + expect(removeMessageByInstanceId).toHaveBeenCalledWith('old-inst'); + }); + + it('allows multiple tooltips on different targets simultaneously', async () => { + const { loadTooltipComponent } = await import('./message-component-manager'); + const { resolveMessageProperties } = await import('./gist-properties-manager'); + + addTargetElement('#target-a'); + addTargetElement('#target-b'); + + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#target-a')); + const msg1: GistMessage = { + messageId: 'tooltip-a', + tooltipPosition: 'bottom', + elementId: '#target-a', + properties: { gist: { elementId: '#target-a', tooltipPosition: 'bottom' } }, + }; + await showMessage(msg1); + + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#target-b')); + const msg2: GistMessage = { + messageId: 'tooltip-b', + tooltipPosition: 'top', + elementId: '#target-b', + properties: { gist: { elementId: '#target-b', tooltipPosition: 'top' } }, + }; + await showMessage(msg2); + + expect(loadTooltipComponent).toHaveBeenCalledTimes(2); + expect(mockGist.currentMessages).toHaveLength(2); + }); + + it('hideMessage calls resetTooltipState for tooltip messages', async () => { + const { getCurrentDisplayType, removeMessageByInstanceId } = + await import('../utilities/message-utils'); + const { hideTooltipComponent } = await import('./message-component-manager'); + vi.mocked(getCurrentDisplayType).mockReturnValue('tooltip'); + + const message: GistMessage = { + messageId: 'tooltip-7', + instanceId: 'inst-tooltip', + tooltipPosition: 'bottom', + }; + mockGist.currentMessages = [message]; + + await hideMessage(message); + + expect(mockGist.messageDismissed).toHaveBeenCalledWith(message); + expect(hideTooltipComponent).toHaveBeenCalledWith(message); + expect(removeMessageByInstanceId).toHaveBeenCalledWith('inst-tooltip'); + }); + + describe('handleGistEvents tooltip integration', () => { + async function setupTooltipAndDispatch( + method: string, + parameters: Record = {} + ) { + const { fetchMessageByInstanceId, getCurrentDisplayType } = + await import('../utilities/message-utils'); + const { resolveMessageProperties } = await import('./gist-properties-manager'); + const mocks = await import('./message-component-manager'); + + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#target-btn')); + addTargetElement('#target-btn'); + + const message: GistMessage = { + messageId: 'tooltip-evt', + tooltipPosition: 'bottom', + elementId: '#target-btn', + firstLoad: true, + properties: { gist: { elementId: '#target-btn', tooltipPosition: 'bottom' } }, + }; + + vi.mocked(getCurrentDisplayType).mockReturnValue('tooltip'); + await showMessage(message); + vi.mocked(fetchMessageByInstanceId).mockReturnValue(message); + + const event = new MessageEvent('message', { + data: { + gist: { + method, + instanceId: message.instanceId, + parameters: { route: '/step-1', ...parameters }, + }, + }, + origin: 'https://renderer.test', + }); + window.dispatchEvent(event); + await vi.dynamicImportSettled(); + + return { message, mocks }; + } + + it('calls showTooltipComponent on routeLoaded for tooltip messages', async () => { + const { mocks } = await setupTooltipAndDispatch('routeLoaded'); + expect(mocks.showTooltipComponent).toHaveBeenCalled(); + expect(mocks.showOverlayComponent).not.toHaveBeenCalled(); + }); + + it('emits messageShown on first tooltip routeLoaded', async () => { + await setupTooltipAndDispatch('routeLoaded'); + expect(mockGist.messageShown).toHaveBeenCalled(); + }); + + it('does not emit messageShown when showTooltipComponent returns false', async () => { + const { fetchMessageByInstanceId, getCurrentDisplayType } = + await import('../utilities/message-utils'); + const { resolveMessageProperties } = await import('./gist-properties-manager'); + const mocks = await import('./message-component-manager'); + + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#target-btn')); + vi.mocked(mocks.showTooltipComponent).mockReturnValue(false); + addTargetElement('#target-btn'); + + const message: GistMessage = { + messageId: 'tooltip-no-fit', + tooltipPosition: 'bottom', + elementId: '#target-btn', + firstLoad: true, + properties: { gist: { elementId: '#target-btn', tooltipPosition: 'bottom' } }, + }; + + vi.mocked(getCurrentDisplayType).mockReturnValue('tooltip'); + await showMessage(message); + vi.mocked(fetchMessageByInstanceId).mockReturnValue(message); + + const event = new MessageEvent('message', { + data: { + gist: { + method: 'routeLoaded', + instanceId: message.instanceId, + parameters: { route: '/step-1' }, + }, + }, + origin: 'https://renderer.test', + }); + window.dispatchEvent(event); + await vi.dynamicImportSettled(); + + expect(mocks.showTooltipComponent).toHaveBeenCalled(); + expect(mockGist.messageShown).not.toHaveBeenCalled(); + expect(mockGist.messageError).toHaveBeenCalledWith(message); + expect(mocks.hideTooltipComponent).toHaveBeenCalledWith(message); + expect(message.firstLoad).toBe(false); + }); + + it('calls resizeTooltipComponent on sizeChanged for tooltip messages', async () => { + const { mocks } = await setupTooltipAndDispatch('sizeChanged', { + width: 280, + height: 150, + }); + expect(mocks.resizeTooltipComponent).toHaveBeenCalled(); + expect(mocks.resizeComponent).not.toHaveBeenCalled(); + }); + + it('calls resetTooltipState on error for tooltip messages', async () => { + const { mocks } = await setupTooltipAndDispatch('error'); + expect(mockGist.messageError).toHaveBeenCalled(); + expect(mocks.hideTooltipComponent).toHaveBeenCalled(); + }); + + it('resets tooltip on routeLoaded when target element is gone', async () => { + const { fetchMessageByInstanceId, getCurrentDisplayType } = + await import('../utilities/message-utils'); + const { resolveMessageProperties } = await import('./gist-properties-manager'); + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#vanishing')); + + const target = addTargetElement('#vanishing'); + + const message: GistMessage = { + messageId: 'tooltip-vanish', + tooltipPosition: 'bottom', + elementId: '#vanishing', + firstLoad: true, + properties: { gist: { elementId: '#vanishing', tooltipPosition: 'bottom' } }, + }; + + vi.mocked(getCurrentDisplayType).mockReturnValue('tooltip'); + await showMessage(message); + vi.mocked(fetchMessageByInstanceId).mockReturnValue(message); + + target.remove(); + + const event = new MessageEvent('message', { + data: { + gist: { + method: 'routeLoaded', + instanceId: message.instanceId, + parameters: { route: '/step-1' }, + }, + }, + origin: 'https://renderer.test', + }); + window.dispatchEvent(event); + await vi.dynamicImportSettled(); + + expect(mockGist.messageError).toHaveBeenCalledWith(message); + expect(message.firstLoad).toBe(false); + }); + + it('gracefully handles invalid selector in routeLoaded instead of throwing', async () => { + const { fetchMessageByInstanceId, getCurrentDisplayType } = + await import('../utilities/message-utils'); + const { resolveMessageProperties } = await import('./gist-properties-manager'); + const mocks = await import('./message-component-manager'); + + vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#valid-target')); + addTargetElement('#valid-target'); + + const message: GistMessage = { + messageId: 'tooltip-invalid-sel', + tooltipPosition: 'bottom', + elementId: '#valid-target', + firstLoad: true, + properties: { gist: { elementId: '#valid-target', tooltipPosition: 'bottom' } }, + }; + + vi.mocked(getCurrentDisplayType).mockReturnValue('tooltip'); + await showMessage(message); + vi.mocked(fetchMessageByInstanceId).mockReturnValue(message); + + message.firstLoad = true; + message.properties = { gist: { elementId: '[invalid!', tooltipPosition: 'bottom' } }; + + const event = new MessageEvent('message', { + data: { + gist: { + method: 'routeLoaded', + instanceId: message.instanceId, + parameters: { route: '/step-1' }, + }, + }, + origin: 'https://renderer.test', + }); + window.dispatchEvent(event); + await vi.dynamicImportSettled(); + + expect(mockGist.messageError).toHaveBeenCalledWith(message); + expect(mocks.showTooltipComponent).not.toHaveBeenCalled(); + expect(message.firstLoad).toBe(false); + }); + }); + }); }); diff --git a/src/managers/message-manager.ts b/src/managers/message-manager.ts index 8269855..3542de9 100644 --- a/src/managers/message-manager.ts +++ b/src/managers/message-manager.ts @@ -12,9 +12,13 @@ import { showEmbedComponent, hideEmbedComponent, resizeComponent, + resizeTooltipComponent, elementHasHeight, changeOverlayTitle, sendDisplaySettingsToIframe, + loadTooltipComponent, + showTooltipComponent, + hideTooltipComponent, } from './message-component-manager'; import { resolveMessageProperties } from './gist-properties-manager'; import { positions, addPageElement } from './page-component-manager'; @@ -40,6 +44,7 @@ import { updateMessageByInstanceId, hasDisplayChanged, applyDisplaySettings, + getCurrentDisplayType, } from '../utilities/message-utils'; import { updatePreviewBarMessage, @@ -58,33 +63,101 @@ interface GistEventData { } export async function showMessage(message: GistMessage): Promise { - if (Gist.isDocumentVisible) { - if (isQueueIdAlreadyShowing(message.queueId)) { - log(`Message with queueId ${message.queueId} is already showing.`); - return null; - } - if (Gist.overlayInstanceId) { - log(`Message ${Gist.overlayInstanceId} already showing.`); + if (!Gist.isDocumentVisible) { + log('Document hidden, not showing message now.'); + return null; + } + + if (isQueueIdAlreadyShowing(message.queueId)) { + log(`Message with queueId ${message.queueId} is already showing.`); + return null; + } + + const properties = resolveMessageProperties(message); + + // Detect tooltip from properties if not already set on message + if (!message.tooltipPosition && properties.hasTooltipPosition) { + message.tooltipPosition = properties.tooltipPosition; + } + + // Route to tooltip flow + if (message.tooltipPosition) { + return showTooltipMessage(message, properties); + } + + // Original overlay flow + if (Gist.overlayInstanceId) { + log(`Message ${Gist.overlayInstanceId} already showing.`); + return null; + } + + message.instanceId = uuidv4(); + message.overlay = true; + message.firstLoad = true; + message.shouldResizeHeight = true; + message.shouldScale = properties.shouldScale; + message.renderStartTime = new Date().getTime(); + Gist.overlayInstanceId = message.instanceId; + Gist.currentMessages.push(message); + + const savedStep = message.savedStepName || null; + return loadMessageComponent(message, null, savedStep); +} + +function showTooltipMessage( + message: GistMessage, + properties: ReturnType +): GistMessage | null { + const targetSelector = properties.elementId || message.elementId; + if (!targetSelector) { + log(`No target selector specified for tooltip message ${message.messageId}`); + Gist.messageError(message); + return null; + } + + // Verify target element exists in the DOM + try { + const targetElement = document.querySelector(targetSelector); + if (!targetElement) { + log( + `Tooltip target element "${targetSelector}" not found for message ${message.messageId}, skipping display` + ); + Gist.messageError(message); return null; - } else { - const properties = resolveMessageProperties(message); - - message.instanceId = uuidv4(); - message.overlay = true; - message.firstLoad = true; - message.shouldResizeHeight = true; - message.shouldScale = properties.shouldScale; - message.renderStartTime = new Date().getTime(); - Gist.overlayInstanceId = message.instanceId; - Gist.currentMessages.push(message); - - const savedStep = message.savedStepName || null; - return loadMessageComponent(message, null, savedStep); } - } else { - log('Document hidden, not showing message now.'); + } catch { + log(`Invalid tooltip target selector "${targetSelector}" for message ${message.messageId}`); + Gist.messageError(message); return null; } + + const existingTooltip = Gist.currentMessages.find( + (m) => m.tooltipPosition && m.elementId === targetSelector + ); + if (existingTooltip) { + log( + `Tooltip already showing on target "${targetSelector}" (instance ${existingTooltip.instanceId}), dismissing it first` + ); + Gist.messageDismissed(existingTooltip); + hideTooltipComponent(existingTooltip); + if (existingTooltip.instanceId) { + removeMessageByInstanceId(existingTooltip.instanceId); + } + } + + message.instanceId = uuidv4(); + message.overlay = false; + message.firstLoad = true; + message.shouldResizeHeight = false; + message.shouldScale = false; + message.renderStartTime = new Date().getTime(); + + message.elementId = targetSelector; + + Gist.currentMessages.push(message); + + const savedStep = message.savedStepName || null; + return loadMessageComponent(message, null, savedStep); } export function embedMessage(message: GistMessage, elementId: string): GistMessage | null { @@ -127,7 +200,10 @@ export async function hideMessage(message: GistMessage): Promise { } export async function resetMessage(message: GistMessage): Promise { - if (message.overlay) { + const displayType = getCurrentDisplayType(message); + if (displayType === 'tooltip') { + resetTooltipState(message); + } else if (message.overlay) { await resetOverlayState(true, message); } else { resetEmbedState(message); @@ -161,6 +237,21 @@ function resetEmbedState(message: GistMessage): void { } } +function resetTooltipState(message: GistMessage): void { + hideTooltipComponent(message); + if (message.instanceId) { + removeMessageByInstanceId(message.instanceId); + } + if (Gist.currentMessages.length === 0) { + window.removeEventListener('message', handleGistEvents); + window.removeEventListener('touchstart', handleTouchStartEvents); + } + if (Gist.config.isPreviewSession) { + clearPreviewBarMessage(); + exitPreviewSession(); + } +} + async function resetOverlayState(hideFirst: boolean, message: GistMessage): Promise { if (hideFirst) { await hideOverlayComponent(); @@ -212,7 +303,11 @@ function loadMessageComponent( window.addEventListener('message', handleGistEvents); window.addEventListener('touchstart', handleTouchStartEvents); - if (elementId) { + const displayType = getCurrentDisplayType(message); + + if (displayType === 'tooltip') { + loadTooltipComponent(url, message, options, stepName); + } else if (elementId) { if (positions.includes(elementId)) { addPageElement(elementId); } @@ -273,7 +368,43 @@ async function handleGistEvents(e: MessageEvent): Promise { updatePreviewBarMessage(currentMessage); } if (currentMessage.firstLoad || currentMessage.isDisplayChange) { - if (currentMessage.overlay) { + const displayType = getCurrentDisplayType(currentMessage); + + if (displayType === 'tooltip') { + const targetSelector = + (currentMessage.properties?.gist?.elementId as string | undefined) || + currentMessage.elementId || + undefined; + let targetFound = false; + try { + targetFound = !!targetSelector && !!document.querySelector(targetSelector); + } catch { + log( + `Invalid tooltip target selector "${targetSelector}" for message ${currentMessage.messageId}` + ); + } + if (!targetFound) { + log( + `Tooltip target not found for "${targetSelector}", emitting error and skipping display` + ); + Gist.messageError(currentMessage); + currentMessage.firstLoad = false; + currentMessage.isDisplayChange = false; + resetTooltipState(currentMessage); + break; + } + const tooltipVisible = showTooltipComponent(currentMessage); + if (!tooltipVisible) { + log( + `Tooltip positioning failed for "${targetSelector}", emitting error and cleaning up` + ); + Gist.messageError(currentMessage); + currentMessage.firstLoad = false; + currentMessage.isDisplayChange = false; + resetTooltipState(currentMessage); + break; + } + } else if (currentMessage.overlay) { showOverlayComponent(currentMessage); } else { showEmbedComponent(currentMessage.elementId!); @@ -388,7 +519,13 @@ async function handleGistEvents(e: MessageEvent): Promise { log( `Size Changed Width: ${data.gist.parameters.width} - Height: ${data.gist.parameters.height}` ); - if (!currentMessage.elementId || currentMessage.shouldResizeHeight) { + const sizeDisplayType = getCurrentDisplayType(currentMessage); + if (sizeDisplayType === 'tooltip') { + resizeTooltipComponent( + currentMessage, + data.gist.parameters as { width: number; height: number } + ); + } else if (!currentMessage.elementId || currentMessage.shouldResizeHeight) { resizeComponent( currentMessage, data.gist.parameters as { width: number; height: number } @@ -411,7 +548,10 @@ async function handleGistEvents(e: MessageEvent): Promise { case 'error': case 'routeError': { Gist.messageError(currentMessage); - if (Gist.overlayInstanceId) { + const displayType = getCurrentDisplayType(currentMessage); + if (displayType === 'tooltip') { + resetTooltipState(currentMessage); + } else if (Gist.overlayInstanceId) { await resetOverlayState(false, currentMessage); } else { resetEmbedState(currentMessage); @@ -429,8 +569,19 @@ async function reloadMessageWithNewDisplay( message.isDisplayChange = true; message.renderStartTime = new Date().getTime(); + const displayType = getCurrentDisplayType(message); const elementId = message.elementId || null; + if (displayType === 'tooltip') { + if (Gist.overlayInstanceId === message.instanceId) { + Gist.overlayInstanceId = null; + } + message.shouldScale = false; + message.shouldResizeHeight = false; + loadMessageComponent(message, null, stepName); + return; + } + if (elementId) { const existingMessage = fetchMessageByElementId(elementId); if (existingMessage && existingMessage.instanceId !== message.instanceId) { @@ -458,7 +609,10 @@ async function reloadMessageWithNewDisplay( } export async function hideMessageVisually(message: GistMessage): Promise { - if (message.overlay) { + const displayType = getCurrentDisplayType(message); + if (displayType === 'tooltip') { + hideTooltipComponent(message); + } else if (message.overlay) { await hideOverlayComponent(); } else if (message.elementId) { hideEmbedComponent(message.elementId); diff --git a/src/managers/tooltip-position-manager.test.ts b/src/managers/tooltip-position-manager.test.ts index e721efa..a91e29f 100644 --- a/src/managers/tooltip-position-manager.test.ts +++ b/src/managers/tooltip-position-manager.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { findTargetElement, positionTooltip } from './tooltip-position-manager'; +import { findTargetElement, positionTooltip, type TooltipHandle } from './tooltip-position-manager'; import { log } from '../utilities/log'; vi.mock('../utilities/log', () => ({ log: vi.fn() })); @@ -49,7 +49,7 @@ function createTooltip(rect: Partial): HTMLElement { } describe('tooltip-position-manager', () => { - let cleanup: (() => void) | null = null; + let handle: TooltipHandle | null = null; beforeEach(() => { document.body.innerHTML = ''; @@ -66,8 +66,8 @@ describe('tooltip-position-manager', () => { }); afterEach(() => { - cleanup?.(); - cleanup = null; + handle?.cleanup(); + handle = null; vi.unstubAllGlobals(); }); @@ -100,48 +100,50 @@ describe('tooltip-position-manager', () => { expect(result).toBeNull(); }); - it('returns a cleanup function on success', () => { + it('returns a handle with cleanup and reposition on success', () => { createTarget({}); const tooltip = createTooltip({}); - cleanup = positionTooltip(tooltip, '#target', 'top'); - expect(typeof cleanup).toBe('function'); + handle = positionTooltip(tooltip, '#target', 'top'); + expect(handle).not.toBeNull(); + expect(typeof handle!.cleanup).toBe('function'); + expect(typeof handle!.reposition).toBe('function'); }); it('positions tooltip above target for "top"', () => { createTarget({ top: 200, bottom: 240, left: 300, right: 400, width: 100, height: 40 }); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'top'); + handle = positionTooltip(tooltip, '#target', 'top'); expect(tooltip.style.position).toBe('absolute'); - expect(tooltip.style.top).toBe('142px'); + expect(tooltip.style.top).toBe('134px'); expect(tooltip.style.left).toBe('290px'); }); it('positions tooltip below target for "bottom"', () => { createTarget({ top: 100, bottom: 140, left: 300, right: 400, width: 100, height: 40 }); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'bottom'); + handle = positionTooltip(tooltip, '#target', 'bottom'); - expect(tooltip.style.top).toBe('148px'); + expect(tooltip.style.top).toBe('156px'); expect(tooltip.style.left).toBe('290px'); }); it('positions tooltip to the left of target for "left"', () => { createTarget({ top: 200, bottom: 240, left: 300, right: 400, width: 100, height: 40 }); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'left'); + handle = positionTooltip(tooltip, '#target', 'left'); expect(tooltip.style.top).toBe('195px'); - expect(tooltip.style.left).toBe('172px'); + expect(tooltip.style.left).toBe('164px'); }); it('positions tooltip to the right of target for "right"', () => { createTarget({ top: 200, bottom: 240, left: 300, right: 400, width: 100, height: 40 }); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'right'); + handle = positionTooltip(tooltip, '#target', 'right'); expect(tooltip.style.top).toBe('195px'); - expect(tooltip.style.left).toBe('408px'); + expect(tooltip.style.left).toBe('416px'); }); it('accounts for scroll offset in positioning', () => { @@ -150,9 +152,9 @@ describe('tooltip-position-manager', () => { createTarget({ top: 200, bottom: 240, left: 300, right: 400, width: 100, height: 40 }); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'bottom'); + handle = positionTooltip(tooltip, '#target', 'bottom'); - expect(tooltip.style.top).toBe('348px'); + expect(tooltip.style.top).toBe('356px'); expect(tooltip.style.left).toBe('340px'); }); @@ -160,45 +162,204 @@ describe('tooltip-position-manager', () => { it('flips from top to bottom when tooltip would overflow above viewport', () => { createTarget({ top: 30, bottom: 70, left: 300, right: 400, width: 100, height: 40 }); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'top'); + handle = positionTooltip(tooltip, '#target', 'top'); - expect(parseFloat(tooltip.style.top)).toBe(78); + expect(parseFloat(tooltip.style.top)).toBe(86); }); it('flips from bottom to top when tooltip would overflow below viewport', () => { Object.defineProperty(window, 'innerHeight', { value: 768, configurable: true }); createTarget({ top: 700, bottom: 740, left: 300, right: 400, width: 100, height: 40 }); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'bottom'); + handle = positionTooltip(tooltip, '#target', 'bottom'); - expect(parseFloat(tooltip.style.top)).toBe(642); + expect(parseFloat(tooltip.style.top)).toBe(634); }); it('flips from left to right when tooltip would overflow left of viewport', () => { createTarget({ top: 200, bottom: 240, left: 30, right: 130, width: 100, height: 40 }); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'left'); + handle = positionTooltip(tooltip, '#target', 'left'); - expect(parseFloat(tooltip.style.left)).toBe(138); + expect(parseFloat(tooltip.style.left)).toBe(146); }); it('flips from right to left when tooltip would overflow right of viewport', () => { Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true }); createTarget({ top: 200, bottom: 240, left: 900, right: 1000, width: 100, height: 40 }); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'right'); + handle = positionTooltip(tooltip, '#target', 'right'); - expect(parseFloat(tooltip.style.left)).toBe(772); + expect(parseFloat(tooltip.style.left)).toBe(764); }); - it('keeps original position when flipped position also does not fit', () => { + it('hides tooltip when target scrolls out of viewport', () => { + const target = createTarget({ + top: -100, + bottom: -60, + left: 300, + right: 400, + width: 100, + height: 40, + }); + const tooltip = createTooltip({ width: 120, height: 50 }); + handle = positionTooltip(tooltip, '#target', 'bottom'); + + expect(tooltip.style.display).toBe('none'); + + (target.getBoundingClientRect as ReturnType).mockReturnValue({ + top: 200, + bottom: 240, + left: 300, + right: 400, + width: 100, + height: 40, + x: 300, + y: 200, + toJSON: () => ({}), + }); + + window.dispatchEvent(new Event('scroll')); + + expect(tooltip.style.display).toBe(''); + }); + + it('hides tooltip when no position fits 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: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'top'); + handle = positionTooltip(tooltip, '#target', 'top'); + + expect(tooltip.style.display).toBe('none'); + }); + + it('flips from left to right and clamps cross-axis when target is near top-left corner', () => { + Object.defineProperty(window, 'innerWidth', { value: 400, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: 300, configurable: true }); + + createTarget({ + top: 10, + bottom: 50, + left: 10, + right: 90, + width: 80, + height: 40, + }); + const tooltip = createTooltip({ width: 200, height: 100 }); + handle = positionTooltip(tooltip, '#target', 'left'); + + expect(handle).not.toBeNull(); + expect(tooltip.style.display).not.toBe('none'); + // left doesn't fit primary axis, flips to right: left = 90 + 16 = 106 + expect(parseFloat(tooltip.style.left)).toBe(106); + // cross-axis top = 10 + (40-100)/2 = -20, clamped to VIEWPORT_PADDING = 4 + expect(parseFloat(tooltip.style.top)).toBe(4); + }); + + it('clamps cross-axis left when bottom tooltip overflows left of viewport', () => { + // Target near left edge: tooltip (width 280) centered on target at left=20 + // would compute left = 20 + (60-280)/2 = -90, which overflows left. + // Previously this was rejected outright; now it should clamp to VIEWPORT_PADDING. + createTarget({ top: 100, bottom: 140, left: 20, right: 80, width: 60, height: 40 }); + const tooltip = createTooltip({ width: 280, height: 50 }); + handle = positionTooltip(tooltip, '#target', 'bottom'); + + expect(handle).not.toBeNull(); + expect(tooltip.style.display).not.toBe('none'); + expect(parseFloat(tooltip.style.left)).toBe(4); // VIEWPORT_PADDING + }); + + it('clamps cross-axis right when bottom tooltip overflows right of viewport', () => { + // Target near right edge: tooltip (width 280) centered on target at left=900 + // would compute left = 900 + (60-280)/2 = 790, right edge = 1070 > 1024 + createTarget({ top: 100, bottom: 140, left: 900, right: 960, width: 60, height: 40 }); + const tooltip = createTooltip({ width: 280, height: 50 }); + handle = positionTooltip(tooltip, '#target', 'bottom'); + + expect(handle).not.toBeNull(); + expect(tooltip.style.display).not.toBe('none'); + // maxLeft = 1024 - 280 - 4 = 740 + expect(parseFloat(tooltip.style.left)).toBe(740); + }); + + it('clamps cross-axis top when right tooltip overflows top of viewport', () => { + // Target near top edge: tooltip (height 120) centered on target at top=20 + // would compute top = 20 + (40-120)/2 = -20, which overflows top. + createTarget({ top: 20, bottom: 60, left: 100, right: 160, width: 60, height: 40 }); + const tooltip = createTooltip({ width: 120, height: 120 }); + handle = positionTooltip(tooltip, '#target', 'right'); - expect(parseFloat(tooltip.style.top)).toBe(-28); + expect(handle).not.toBeNull(); + expect(tooltip.style.display).not.toBe('none'); + expect(parseFloat(tooltip.style.top)).toBe(4); // VIEWPORT_PADDING + }); + + it('clamps cross-axis bottom when left tooltip overflows bottom of viewport', () => { + // Target near bottom edge: tooltip (height 120) centered on target at top=700 + // would compute top = 700 + (40-120)/2 = 660, bottom edge = 780 > 768 + createTarget({ top: 700, bottom: 740, left: 300, right: 360, width: 60, height: 40 }); + const tooltip = createTooltip({ width: 120, height: 120 }); + handle = positionTooltip(tooltip, '#target', 'left'); + + expect(handle).not.toBeNull(); + expect(tooltip.style.display).not.toBe('none'); + // maxTop = 768 - 120 - 4 = 644 + expect(parseFloat(tooltip.style.top)).toBe(644); + }); + + it('tracks arrow offset when cross-axis is clamped', () => { + // Arrow element should get an offset when the tooltip is shifted + const arrowEl = document.createElement('div'); + arrowEl.className = 'gist-tooltip-arrow'; + + createTarget({ top: 100, bottom: 140, left: 20, right: 80, width: 60, height: 40 }); + const tooltip = createTooltip({ width: 280, height: 50 }); + tooltip.appendChild(arrowEl); + handle = positionTooltip(tooltip, '#target', 'bottom'); + + expect(handle).not.toBeNull(); + // Centered left = 20 + (60-280)/2 = -90. Clamped to 4. + // arrowOffset = -90 - 4 = -94, but capped by maxArrowShift = 280/2 - 16 - 4 = 120 + // So arrowOffset = -94 (within bounds) + expect(arrowEl.style.left).not.toBe('50%'); + expect(arrowEl.style.left).toContain('calc(50%'); + }); + + it('re-shows tooltip when a valid position becomes available after being hidden', () => { + const target = createTarget({ + top: 10, + bottom: 50, + left: 10, + right: 90, + width: 80, + height: 40, + }); + const tooltip = createTooltip({ width: 200, height: 100 }); + + Object.defineProperty(window, 'innerWidth', { value: 100, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: 100, configurable: true }); + + handle = positionTooltip(tooltip, '#target', 'bottom'); + expect(tooltip.style.display).toBe('none'); + + // Viewport grows, target moves to a position where bottom fits + Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: 768, configurable: true }); + (target.getBoundingClientRect as ReturnType).mockReturnValue({ + top: 200, + bottom: 240, + left: 300, + right: 380, + width: 80, + height: 40, + x: 300, + y: 200, + toJSON: () => ({}), + }); + + window.dispatchEvent(new Event('resize')); + expect(tooltip.style.display).toBe(''); }); }); @@ -219,7 +380,7 @@ describe('tooltip-position-manager', () => { const target = createTarget({}); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'bottom'); + handle = positionTooltip(tooltip, '#target', 'bottom'); target.remove(); @@ -240,7 +401,7 @@ describe('tooltip-position-manager', () => { createTarget({}); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'bottom'); + handle = positionTooltip(tooltip, '#target', 'bottom'); tooltip.remove(); @@ -255,13 +416,13 @@ describe('tooltip-position-manager', () => { it('cleanup is idempotent and can be called multiple times safely', () => { createTarget({}); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'bottom'); + handle = positionTooltip(tooltip, '#target', 'bottom'); - cleanup!(); - cleanup!(); - cleanup!(); + handle!.cleanup(); + handle!.cleanup(); + handle!.cleanup(); - cleanup = null; + handle = null; }); }); @@ -269,7 +430,7 @@ describe('tooltip-position-manager', () => { it('repositions on scroll events', () => { const target = createTarget({}); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'bottom'); + handle = positionTooltip(tooltip, '#target', 'bottom'); const callCount = (target.getBoundingClientRect as ReturnType).mock.calls .length; @@ -284,7 +445,7 @@ describe('tooltip-position-manager', () => { it('repositions on resize events', () => { const target = createTarget({}); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'bottom'); + handle = positionTooltip(tooltip, '#target', 'bottom'); const callCount = (target.getBoundingClientRect as ReturnType).mock.calls .length; @@ -299,8 +460,8 @@ describe('tooltip-position-manager', () => { it('cleanup function removes event listeners', () => { const target = createTarget({}); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'bottom'); - cleanup!(); + handle = positionTooltip(tooltip, '#target', 'bottom'); + handle!.cleanup(); const callCount = (target.getBoundingClientRect as ReturnType).mock.calls .length; @@ -311,7 +472,7 @@ describe('tooltip-position-manager', () => { const newCallCount = (target.getBoundingClientRect as ReturnType).mock.calls .length; expect(newCallCount).toBe(callCount); - cleanup = null; + handle = null; }); it('throttles updates via requestAnimationFrame', () => { @@ -325,7 +486,7 @@ describe('tooltip-position-manager', () => { createTarget({}); const tooltip = createTooltip({ width: 120, height: 50 }); - cleanup = positionTooltip(tooltip, '#target', 'bottom'); + handle = positionTooltip(tooltip, '#target', 'bottom'); window.dispatchEvent(new Event('scroll')); window.dispatchEvent(new Event('scroll')); diff --git a/src/managers/tooltip-position-manager.ts b/src/managers/tooltip-position-manager.ts index d273236..796343d 100644 --- a/src/managers/tooltip-position-manager.ts +++ b/src/managers/tooltip-position-manager.ts @@ -1,8 +1,26 @@ import { log } from '../utilities/log'; +import { ARROW_SIZE } from '../templates/tooltip'; export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right'; -const OFFSET = 8; +const ARROW_GAP = ARROW_SIZE + 6; +const VIEWPORT_PADDING = 4; + +const FALLBACK_ORDER: Record = { + top: ['bottom', 'left', 'right'], + bottom: ['top', 'left', 'right'], + left: ['right', 'top', 'bottom'], + right: ['left', 'top', 'bottom'], +}; + +const OVERFLOW_RE = /auto|scroll/; + +const ARROW_CLASS_FOR_POSITION: Record = { + top: 'gist-arrow-bottom', + bottom: 'gist-arrow-top', + left: 'gist-arrow-right', + right: 'gist-arrow-left', +}; export function findTargetElement(selector: string): Element | null { try { @@ -25,44 +43,149 @@ function calculatePosition( switch (position) { case 'top': return { - top: targetRect.top - tooltipRect.height - OFFSET, + top: targetRect.top - tooltipRect.height - ARROW_GAP, left: targetRect.left + (targetRect.width - tooltipRect.width) / 2, }; case 'bottom': return { - top: targetRect.bottom + OFFSET, + top: targetRect.bottom + ARROW_GAP, left: targetRect.left + (targetRect.width - tooltipRect.width) / 2, }; case 'left': return { top: targetRect.top + (targetRect.height - tooltipRect.height) / 2, - left: targetRect.left - tooltipRect.width - OFFSET, + left: targetRect.left - tooltipRect.width - ARROW_GAP, }; case 'right': return { top: targetRect.top + (targetRect.height - tooltipRect.height) / 2, - left: targetRect.right + OFFSET, + left: targetRect.right + ARROW_GAP, }; } } -function getFlippedPosition(position: TooltipPosition): TooltipPosition { - const flips: Record = { - top: 'bottom', - bottom: 'top', - left: 'right', - right: 'left', - }; - return flips[position]; +function isTargetVisible(targetRect: DOMRect, scrollAncestors: Element[]): boolean { + if ( + targetRect.bottom <= 0 || + targetRect.top >= window.innerHeight || + targetRect.right <= 0 || + targetRect.left >= window.innerWidth + ) { + return false; + } + + for (const ancestor of scrollAncestors) { + const ancestorRect = ancestor.getBoundingClientRect(); + if ( + targetRect.bottom <= ancestorRect.top || + targetRect.top >= ancestorRect.bottom || + targetRect.right <= ancestorRect.left || + targetRect.left >= ancestorRect.right + ) { + return false; + } + } + + return true; } -function fitsInViewport(coords: { top: number; left: number }, tooltipRect: DOMRect): boolean { - return ( - coords.top >= 0 && - coords.left >= 0 && - coords.top + tooltipRect.height <= window.innerHeight && - coords.left + tooltipRect.width <= window.innerWidth - ); +function fitsPrimaryAxis( + coords: { top: number; left: number }, + tooltipRect: DOMRect, + position: TooltipPosition +): boolean { + if (position === 'top' || position === 'bottom') { + return coords.top >= 0 && coords.top + tooltipRect.height <= window.innerHeight; + } + return coords.left >= 0 && coords.left + tooltipRect.width <= window.innerWidth; +} + +function fitsCrossAxis(tooltipRect: DOMRect, position: TooltipPosition): boolean { + if (position === 'top' || position === 'bottom') { + return tooltipRect.width + VIEWPORT_PADDING * 2 <= window.innerWidth; + } + return tooltipRect.height + VIEWPORT_PADDING * 2 <= window.innerHeight; +} + +interface PositionResult { + top: number; + left: number; + position: TooltipPosition; + arrowOffset: number | null; +} + +function clampCrossAxis( + coords: { top: number; left: number }, + tooltipRect: DOMRect, + position: TooltipPosition +): PositionResult { + let { top, left } = coords; + let arrowOffset: number | null = null; + + if (position === 'top' || position === 'bottom') { + const minLeft = VIEWPORT_PADDING; + const maxLeft = window.innerWidth - tooltipRect.width - VIEWPORT_PADDING; + if (maxLeft >= minLeft) { + if (left < minLeft) { + arrowOffset = left - minLeft; + left = minLeft; + } else if (left > maxLeft) { + arrowOffset = left - maxLeft; + left = maxLeft; + } + } + } else { + const minTop = VIEWPORT_PADDING; + const maxTop = window.innerHeight - tooltipRect.height - VIEWPORT_PADDING; + if (maxTop >= minTop) { + if (top < minTop) { + arrowOffset = top - minTop; + top = minTop; + } else if (top > maxTop) { + arrowOffset = top - maxTop; + top = maxTop; + } + } + } + + // Ensure the arrow offset doesn't push the arrow outside the tooltip + if (arrowOffset !== null) { + const halfTooltip = + position === 'top' || position === 'bottom' ? tooltipRect.width / 2 : tooltipRect.height / 2; + const maxArrowShift = halfTooltip - ARROW_GAP - VIEWPORT_PADDING; + if (Math.abs(arrowOffset) > maxArrowShift) { + arrowOffset = arrowOffset > 0 ? maxArrowShift : -maxArrowShift; + } + } + + return { top, left, position, arrowOffset }; +} + +function tryPosition( + tooltipRect: DOMRect, + targetRect: DOMRect, + position: TooltipPosition +): PositionResult | null { + const coords = calculatePosition(tooltipRect, targetRect, position); + if (!fitsPrimaryAxis(coords, tooltipRect, position)) return null; + if (!fitsCrossAxis(tooltipRect, position)) return null; + return clampCrossAxis(coords, tooltipRect, position); +} + +function findBestPosition( + tooltipRect: DOMRect, + targetRect: DOMRect, + preferred: TooltipPosition +): PositionResult | null { + const preferredResult = tryPosition(tooltipRect, targetRect, preferred); + if (preferredResult) return preferredResult; + + for (const fallback of FALLBACK_ORDER[preferred]) { + const result = tryPosition(tooltipRect, targetRect, fallback); + if (result) return result; + } + + return null; } function applyPosition(tooltipElement: HTMLElement, coords: { top: number; left: number }): void { @@ -71,11 +194,61 @@ function applyPosition(tooltipElement: HTMLElement, coords: { top: number; left: tooltipElement.style.left = `${coords.left + window.scrollX}px`; } +function updateArrow(tooltipElement: HTMLElement, result: PositionResult): void { + const arrowEl = tooltipElement.querySelector('.gist-tooltip-arrow') as HTMLElement | null; + if (!arrowEl) return; + + arrowEl.classList.remove( + 'gist-arrow-top', + 'gist-arrow-bottom', + 'gist-arrow-left', + 'gist-arrow-right' + ); + arrowEl.classList.add(ARROW_CLASS_FOR_POSITION[result.position]); + + if (result.arrowOffset !== null) { + if (result.position === 'top' || result.position === 'bottom') { + arrowEl.style.left = `calc(50% + ${result.arrowOffset}px)`; + arrowEl.style.removeProperty('top'); + } else { + arrowEl.style.top = `calc(50% + ${result.arrowOffset}px)`; + arrowEl.style.removeProperty('left'); + } + } else { + if (result.position === 'top' || result.position === 'bottom') { + arrowEl.style.left = '50%'; + arrowEl.style.removeProperty('top'); + } else { + arrowEl.style.top = '50%'; + arrowEl.style.removeProperty('left'); + } + } +} + +function getScrollableAncestors(element: Element): Element[] { + const ancestors: Element[] = []; + let current = element.parentElement; + while (current) { + const style = getComputedStyle(current); + const overflow = style.overflow + style.overflowX + style.overflowY; + if (OVERFLOW_RE.test(overflow)) { + ancestors.push(current); + } + current = current.parentElement; + } + return ancestors; +} + +export interface TooltipHandle { + cleanup: () => void; + reposition: () => void; +} + export function positionTooltip( tooltipElement: HTMLElement, targetSelector: string, position: TooltipPosition -): (() => void) | null { +): TooltipHandle | null { const targetElement = findTargetElement(targetSelector); if (!targetElement) { return null; @@ -83,6 +256,13 @@ export function positionTooltip( let rafId: number | null = null; let cleaned = false; + let scrollAncestors: Element[] = []; + + try { + scrollAncestors = getScrollableAncestors(targetElement); + } catch { + // getComputedStyle may throw in test environments + } function update(): void { if (cleaned) { @@ -96,19 +276,24 @@ export function positionTooltip( } const targetRect = targetElement.getBoundingClientRect(); + + if (!isTargetVisible(targetRect, scrollAncestors)) { + tooltipElement.style.display = 'none'; + return; + } + + tooltipElement.style.display = ''; const tooltipRect = tooltipElement.getBoundingClientRect(); - let coords = calculatePosition(tooltipRect, targetRect, position); + const result = findBestPosition(tooltipRect, targetRect, position); - if (!fitsInViewport(coords, tooltipRect)) { - const flipped = getFlippedPosition(position); - const flippedCoords = calculatePosition(tooltipRect, targetRect, flipped); - if (fitsInViewport(flippedCoords, tooltipRect)) { - coords = flippedCoords; - } + if (!result) { + tooltipElement.style.display = 'none'; + return; } - applyPosition(tooltipElement, coords); + applyPosition(tooltipElement, result); + updateArrow(tooltipElement, result); } function onScrollOrResize(): void { @@ -128,6 +313,9 @@ export function positionTooltip( cleaned = true; window.removeEventListener('scroll', onScrollOrResize); window.removeEventListener('resize', onScrollOrResize); + for (const ancestor of scrollAncestors) { + ancestor.removeEventListener('scroll', onScrollOrResize); + } if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; @@ -142,6 +330,9 @@ export function positionTooltip( window.addEventListener('scroll', onScrollOrResize, { passive: true }); window.addEventListener('resize', onScrollOrResize, { passive: true }); + for (const ancestor of scrollAncestors) { + ancestor.addEventListener('scroll', onScrollOrResize, { passive: true }); + } - return cleanup; + return { cleanup, reposition: update }; } diff --git a/src/templates/tooltip.test.ts b/src/templates/tooltip.test.ts index 7788ebd..118832f 100644 --- a/src/templates/tooltip.test.ts +++ b/src/templates/tooltip.test.ts @@ -39,11 +39,11 @@ describe('tooltipHTMLTemplate', () => { expect(iframe?.getAttribute('src')).toBe('https://example.com/tooltip'); }); - it('contains the gist-tooltip wrapper div', () => { + it('contains the gist-tooltip-outer wrapper div', () => { const html = tooltipHTMLTemplate('el', makeProps(), 'https://example.com'); const doc = parseHTML(html); - const wrapper = doc.querySelector('#gist-tooltip'); + const wrapper = doc.querySelector('.gist-tooltip-outer'); expect(wrapper).not.toBeNull(); }); @@ -51,7 +51,7 @@ describe('tooltipHTMLTemplate', () => { const html = tooltipHTMLTemplate('el', makeProps(), 'https://example.com'); const doc = parseHTML(html); - const container = doc.querySelector('#gist-tooltip-container'); + const container = doc.querySelector('.gist-tooltip-container'); expect(container).not.toBeNull(); }); @@ -185,7 +185,54 @@ describe('tooltipHTMLTemplate', () => { it('uses position: absolute on the wrapper', () => { const html = tooltipHTMLTemplate('el', makeProps(), 'https://example.com'); - expect(html).toContain('#gist-tooltip'); + expect(html).toContain('.gist-tooltip-outer'); expect(html).toContain('position: absolute'); }); + + it('scopes CSS rules under wrapperId when provided', () => { + const html = tooltipHTMLTemplate( + 'el', + makeProps({ messageWidth: 300, tooltipArrowColor: '#f00', tooltipPosition: 'top' }), + 'https://example.com', + 'gist-tooltip-abc123' + ); + + expect(html).toContain('#gist-tooltip-abc123 .gist-tooltip-outer'); + expect(html).toContain('#gist-tooltip-abc123 .gist-tooltip-container'); + expect(html).toContain('#gist-tooltip-abc123 .gist-tooltip-frame'); + expect(html).toContain('#gist-tooltip-abc123 .gist-tooltip-arrow'); + }); + + it('does not add scope prefix when wrapperId is omitted', () => { + const html = tooltipHTMLTemplate('el', makeProps(), 'https://example.com'); + + expect(html).not.toMatch(/#gist-tooltip-/); + expect(html).toMatch(/^\s+\.gist-tooltip-outer\s*\{/m); + }); + + it('isolates styles between two tooltips with different configs', () => { + const htmlA = tooltipHTMLTemplate( + 'el-a', + makeProps({ messageWidth: 280, tooltipArrowColor: '#0057c4', tooltipPosition: 'bottom' }), + 'https://example.com', + 'gist-tooltip-aaa' + ); + const htmlB = tooltipHTMLTemplate( + 'el-b', + makeProps({ messageWidth: 400, tooltipArrowColor: '#ff0000', tooltipPosition: 'top' }), + 'https://example.com', + 'gist-tooltip-bbb' + ); + + expect(htmlA).toContain('#gist-tooltip-aaa .gist-tooltip-frame'); + expect(htmlA).toContain('width: 280px'); + expect(htmlA).toContain('#0057c4'); + + expect(htmlB).toContain('#gist-tooltip-bbb .gist-tooltip-frame'); + expect(htmlB).toContain('width: 400px'); + expect(htmlB).toContain('#ff0000'); + + expect(htmlA).not.toContain('gist-tooltip-bbb'); + expect(htmlB).not.toContain('gist-tooltip-aaa'); + }); }); diff --git a/src/templates/tooltip.ts b/src/templates/tooltip.ts index 411a73c..9792994 100644 --- a/src/templates/tooltip.ts +++ b/src/templates/tooltip.ts @@ -1,6 +1,6 @@ import type { ResolvedMessageProperties } from '../types'; -const ARROW_SIZE = 10; +export const ARROW_SIZE = 10; function getArrowClass(tooltipPosition: string): string { switch (tooltipPosition) { @@ -20,7 +20,8 @@ function getArrowClass(tooltipPosition: string): string { export function tooltipHTMLTemplate( elementId: string, messageProperties: ResolvedMessageProperties, - url: string + url: string, + wrapperId: string = '' ): string { let maxWidthBreakpoint = 600; if (messageProperties.messageWidth > maxWidthBreakpoint) { @@ -29,73 +30,81 @@ export function tooltipHTMLTemplate( const arrowColor = messageProperties.tooltipArrowColor; const arrowClass = getArrowClass(messageProperties.tooltipPosition); + const scope = wrapperId ? `#${wrapperId} ` : ''; const template = ` -
+
-
+
- +
+ +
`; return template;