Skip to content

Commit 2e762b9

Browse files
authored
Auto-scroll tooltip targets into view before positioning (#128)
1 parent f468a85 commit 2e762b9

File tree

7 files changed

+412
-31
lines changed

7 files changed

+412
-31
lines changed

examples/index.html

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ <h3>Programmatic Dismiss</h3>
101101
</div>
102102
</div>
103103

104+
<div class="tooltip-section">
105+
<h3>Auto-Scroll Into View</h3>
106+
<p>Targets that exist in the page but are outside the viewport (or scrolled out of a container) get smoothly scrolled into view before the tooltip appears. Scrolling only happens when the SDK can guarantee the tooltip will render successfully.</p>
107+
<div class="tooltip-btn-row">
108+
<button class="tooltip-target btn-blue" onclick="showTooltipAt('#offscreen-target', 'top')">Offscreen Page Target ↓</button>
109+
<button class="tooltip-target btn-orange" onclick="showTooltipAt('#scroll-item-8', 'right')">Scroll Container Target (Feature H)</button>
110+
</div>
111+
</div>
112+
104113
<div class="tooltip-section">
105114
<h3>Tooltip Event Log</h3>
106115
<div class="tooltip-event-log" id="tooltipEventLog">
@@ -109,6 +118,14 @@ <h3>Tooltip Event Log</h3>
109118
</div>
110119
</div>
111120

121+
<div style="height: 120vh;"></div>
122+
<div style="text-align: center; padding: 40px 20px;">
123+
<button id="offscreen-target" class="tooltip-target btn-green" onclick="showTooltipAt('#offscreen-target', 'top')" style="font-size: 16px; padding: 14px 28px;">
124+
I'm the offscreen target
125+
</button>
126+
<p style="margin-top: 12px; color: #666; font-size: 14px;">This element starts below the fold. The SDK scrolled here because it predicted a valid tooltip placement.</p>
127+
</div>
128+
112129
<div class="config-form-sticky">
113130
<div class="config-form-header" onclick="toggleConfigForm()">
114131
<span>⚙️ Configuration Override & Debugging</span>

src/managers/message-component-manager.test.ts

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from './message-component-manager';
1414
import { log } from '../utilities/log';
1515
import { resolveMessageProperties } from './gist-properties-manager';
16-
import { positionTooltip } from './tooltip-position-manager';
16+
import { positionTooltip, ensureTargetInView } from './tooltip-position-manager';
1717
import type { GistMessage } from '../types';
1818

1919
vi.mock('../utilities/log', () => ({ log: vi.fn() }));
@@ -65,6 +65,7 @@ vi.mock('../utilities/message-utils', () => ({
6565
}));
6666
vi.mock('./tooltip-position-manager', () => ({
6767
positionTooltip: vi.fn(),
68+
ensureTargetInView: vi.fn(() => Promise.resolve(true)),
6869
}));
6970

7071
describe('message-component-manager', () => {
@@ -192,7 +193,7 @@ describe('message-component-manager', () => {
192193
expect(iframe?.onload).toBeTypeOf('function');
193194
});
194195

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

@@ -222,7 +223,7 @@ describe('message-component-manager', () => {
222223
};
223224

224225
loadTooltipComponent('https://view.example.com/index.html', message, baseOptions);
225-
showTooltipComponent(message);
226+
await showTooltipComponent(message);
226227

227228
expect(mockCleanup).not.toHaveBeenCalled();
228229

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

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

261-
const result = showTooltipComponent(message);
262+
const result = await showTooltipComponent(message);
262263

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

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

295-
showTooltipComponent(message);
296+
await showTooltipComponent(message);
296297

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

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

328-
showTooltipComponent(message);
329+
await showTooltipComponent(message);
329330

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

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

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

343-
const result = showTooltipComponent(message);
344+
const result = await showTooltipComponent(message);
344345

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

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

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

367-
const result = showTooltipComponent(message);
368+
const result = await showTooltipComponent(message);
368369

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

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

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

389-
const result = showTooltipComponent(message);
390+
const result = await showTooltipComponent(message);
390391

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

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

404-
const result = showTooltipComponent(message);
405+
const result = await showTooltipComponent(message);
405406

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

411-
it('logs and returns false when no target selector is provided', () => {
412+
it('logs and returns false when no target selector is provided', async () => {
412413
setupTooltipWrapper('inst-1');
413414
const message: GistMessage = {
414415
messageId: 'msg-1',
415416
instanceId: 'inst-1',
416417
};
417418

418-
const result = showTooltipComponent(message);
419+
const result = await showTooltipComponent(message);
419420

420421
expect(result).toBe(false);
421422
expect(log).toHaveBeenCalledWith('No target selector for tooltip inst-1');
@@ -424,6 +425,39 @@ describe('message-component-manager', () => {
424425
const container = document.querySelector('.gist-tooltip-container');
425426
expect(container?.classList.contains('gist-visible')).toBe(false);
426427
});
428+
429+
it('returns false without calling positionTooltip when ensureTargetInView returns false', async () => {
430+
vi.mocked(ensureTargetInView).mockResolvedValue(false);
431+
setupTooltipWrapper('inst-1');
432+
const message: GistMessage = {
433+
messageId: 'msg-1',
434+
instanceId: 'inst-1',
435+
properties: { gist: { elementId: '#target-el' } },
436+
};
437+
438+
const result = await showTooltipComponent(message);
439+
440+
expect(result).toBe(false);
441+
expect(ensureTargetInView).toHaveBeenCalled();
442+
expect(positionTooltip).not.toHaveBeenCalled();
443+
});
444+
445+
it('proceeds to positionTooltip when ensureTargetInView returns true', async () => {
446+
vi.mocked(ensureTargetInView).mockResolvedValue(true);
447+
vi.mocked(positionTooltip).mockReturnValue({ cleanup: vi.fn(), reposition: vi.fn() });
448+
setupTooltipWrapper('inst-1');
449+
const message: GistMessage = {
450+
messageId: 'msg-1',
451+
instanceId: 'inst-1',
452+
properties: { gist: { elementId: '#target-el' } },
453+
};
454+
455+
const result = await showTooltipComponent(message);
456+
457+
expect(result).toBe(true);
458+
expect(ensureTargetInView).toHaveBeenCalled();
459+
expect(positionTooltip).toHaveBeenCalled();
460+
});
427461
});
428462

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

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

482-
showTooltipComponent(message);
516+
await showTooltipComponent(message);
483517
hideTooltipComponent(message);
484518

485519
expect(mockCleanup).toHaveBeenCalled();
@@ -510,7 +544,7 @@ describe('message-component-manager', () => {
510544
return wrapper;
511545
}
512546

513-
it('calls cleanup on all tracked tooltip handles', () => {
547+
it('calls cleanup on all tracked tooltip handles', async () => {
514548
const cleanup1 = vi.fn();
515549
const cleanup2 = vi.fn();
516550
vi.mocked(positionTooltip)
@@ -520,12 +554,12 @@ describe('message-component-manager', () => {
520554
setupTooltipWrapper('inst-1');
521555
setupTooltipWrapper('inst-2');
522556

523-
showTooltipComponent({
557+
await showTooltipComponent({
524558
messageId: 'msg-1',
525559
instanceId: 'inst-1',
526560
properties: { gist: { elementId: '#target-1' } },
527561
});
528-
showTooltipComponent({
562+
await showTooltipComponent({
529563
messageId: 'msg-2',
530564
instanceId: 'inst-2',
531565
properties: { gist: { elementId: '#target-2' } },
@@ -564,12 +598,12 @@ describe('message-component-manager', () => {
564598
expect(() => clearAllTooltipHandles()).not.toThrow();
565599
});
566600

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

571605
setupTooltipWrapper('inst-1');
572-
showTooltipComponent({
606+
await showTooltipComponent({
573607
messageId: 'msg-1',
574608
instanceId: 'inst-1',
575609
properties: { gist: { elementId: '#target-1' } },

src/managers/message-component-manager.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { positions } from './page-component-manager';
88
import { wideOverlayPositions } from '../utilities/message-utils';
99
import {
1010
positionTooltip,
11+
ensureTargetInView,
1112
type TooltipPosition,
1213
type TooltipHandle,
1314
} from './tooltip-position-manager';
@@ -251,7 +252,7 @@ export function loadTooltipComponent(
251252
attachIframeLoadEvent(messageElementId, options, stepName);
252253
}
253254

254-
export function showTooltipComponent(message: GistMessage): boolean {
255+
export async function showTooltipComponent(message: GistMessage): Promise<boolean> {
255256
const instanceId = message.instanceId ?? '';
256257
const messageProperties = resolveMessageProperties(message);
257258
const wrapperId = `gist-tooltip-${instanceId}`;
@@ -281,6 +282,15 @@ export function showTooltipComponent(message: GistMessage): boolean {
281282
}
282283

283284
const position = (messageProperties.tooltipPosition || 'bottom') as TooltipPosition;
285+
286+
const targetReady = await ensureTargetInView(tooltipElement, selector, position);
287+
if (!targetReady) {
288+
log(
289+
`Tooltip for instance ${instanceId} skipped: target "${selector}" is off-screen and cannot be scrolled into a valid position`
290+
);
291+
return false;
292+
}
293+
284294
const handle = positionTooltip(tooltipElement, selector, position);
285295
if (handle) {
286296
const isVisible = tooltipElement.style.display !== 'none';

src/managers/message-manager.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ vi.mock('./message-component-manager', () => ({
5555
changeOverlayTitle: vi.fn(),
5656
sendDisplaySettingsToIframe: vi.fn(),
5757
loadTooltipComponent: vi.fn(),
58-
showTooltipComponent: vi.fn(() => true),
58+
showTooltipComponent: vi.fn(() => Promise.resolve(true)),
5959
hideTooltipComponent: vi.fn(),
6060
}));
6161
vi.mock('./gist-properties-manager', () => ({
@@ -626,7 +626,7 @@ describe('message-manager', () => {
626626
const mocks = await import('./message-component-manager');
627627

628628
vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('#target-btn'));
629-
vi.mocked(mocks.showTooltipComponent).mockReturnValue(false);
629+
vi.mocked(mocks.showTooltipComponent).mockResolvedValue(false);
630630
addTargetElement('#target-btn');
631631

632632
const message: GistMessage = {

src/managers/message-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ async function handleGistEvents(e: MessageEvent): Promise<void> {
393393
resetTooltipState(currentMessage);
394394
break;
395395
}
396-
const tooltipVisible = showTooltipComponent(currentMessage);
396+
const tooltipVisible = await showTooltipComponent(currentMessage);
397397
if (!tooltipVisible) {
398398
log(
399399
`Tooltip positioning failed for "${targetSelector}", emitting error and cleaning up`

0 commit comments

Comments
 (0)