Skip to content

Commit 003b04e

Browse files
authored
Add tooltip support to preview bar and introduce safe findElement utility (#132)
1 parent 1e0186b commit 003b04e

File tree

7 files changed

+401
-37
lines changed

7 files changed

+401
-37
lines changed

examples/index.html

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ <h1>Gist for Web</h1>
4242
<a href="#" class="button" onClick="logIn()">Log In</a>
4343
<a href="#" class="button" onClick="logOut()">Log Out</a>
4444
<a href="#tooltipDemo" class="button" style="background-color:#8b5cf6;">Tooltip Demo ↓</a>
45+
<a href="#" class="button" style="background-color:#b45309;" onClick="enablePreviewBar()">Enable Preview Bar</a>
4546
</div>
4647
<div class="row docs">
4748
<p>More information can be found on our <a target="_blank" href="https://docs.gist.build">docs</a>, if you have any question you can email us at <a target="_blank" href="mailto:support@gist.build">support@gist.build</a></p>
@@ -397,6 +398,39 @@ <h4>Active Messages & Display Settings</h4>
397398
setTimeout(refreshActiveMessages, 500);
398399
});
399400

401+
// ─── Preview Bar Demo ─────────────────────────────
402+
function enablePreviewBar() {
403+
var url = new URL(window.location.href);
404+
if (url.searchParams.has('cioPreviewId')) {
405+
alert('Preview bar is already active.');
406+
return;
407+
}
408+
url.searchParams.set('cioPreviewId', 'local-preview-' + Date.now());
409+
window.location.href = url.toString();
410+
}
411+
412+
// When preview bar is active, auto-show a local HTML message with livePreview
413+
(function() {
414+
var params = new URLSearchParams(window.location.search);
415+
if (!params.has('cioPreviewId')) return;
416+
417+
function showPreviewBarMessage() {
418+
Gist.showMessage({
419+
messageId: 'preview-bar-demo',
420+
position: 'center',
421+
properties: {
422+
gist: {
423+
encodedMessageHtml: EncodedTestHTMLMessage,
424+
livePreview: true
425+
}
426+
}
427+
});
428+
}
429+
430+
// Show the message after a short delay to let the SDK initialize
431+
setTimeout(showPreviewBarMessage, 1000);
432+
})();
433+
400434
// ─── Tooltip Demo ─────────────────────────────────
401435
let activeTooltipInstanceId = null;
402436

src/managers/message-manager.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,22 @@ describe('message-manager', () => {
468468
expect(mockGist.messageError).toHaveBeenCalledWith(message);
469469
});
470470

471+
it('returns null and emits error for invalid selector instead of throwing', async () => {
472+
const { resolveMessageProperties } = await import('./gist-properties-manager');
473+
vi.mocked(resolveMessageProperties).mockReturnValue(tooltipProperties('[invalid!'));
474+
475+
const message: GistMessage = {
476+
messageId: 'tooltip-invalid',
477+
tooltipPosition: 'bottom',
478+
properties: { gist: { elementId: '[invalid!', tooltipPosition: 'bottom' } },
479+
};
480+
481+
const result = await showMessage(message);
482+
483+
expect(result).toBeNull();
484+
expect(mockGist.messageError).toHaveBeenCalledWith(message);
485+
});
486+
471487
it('allows tooltip when an overlay is already active', async () => {
472488
const { loadTooltipComponent } = await import('./message-component-manager');
473489
const { resolveMessageProperties } = await import('./gist-properties-manager');

src/managers/message-manager.ts

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
hideTooltipComponent,
2222
} from './message-component-manager';
2323
import { resolveMessageProperties } from './gist-properties-manager';
24+
import { findElement } from '../utilities/dom';
2425
import { positions, addPageElement } from './page-component-manager';
2526
import { getAllCustomAttributes } from './custom-attribute-manager';
2627
import { checkMessageQueue } from './queue-manager';
@@ -116,17 +117,11 @@ function showTooltipMessage(
116117
}
117118

118119
// Verify target element exists in the DOM
119-
try {
120-
const targetElement = document.querySelector(targetSelector);
121-
if (!targetElement) {
122-
log(
123-
`Tooltip target element "${targetSelector}" not found for message ${message.messageId}, skipping display`
124-
);
125-
Gist.messageError(message);
126-
return null;
127-
}
128-
} catch {
129-
log(`Invalid tooltip target selector "${targetSelector}" for message ${message.messageId}`);
120+
const targetElement = findElement(targetSelector);
121+
if (!targetElement) {
122+
log(
123+
`Tooltip target element "${targetSelector}" not found for message ${message.messageId}, skipping display`
124+
);
130125
Gist.messageError(message);
131126
return null;
132127
}
@@ -375,14 +370,7 @@ async function handleGistEvents(e: MessageEvent): Promise<void> {
375370
(currentMessage.properties?.gist?.elementId as string | undefined) ||
376371
currentMessage.elementId ||
377372
undefined;
378-
let targetFound = false;
379-
try {
380-
targetFound = !!targetSelector && !!document.querySelector(targetSelector);
381-
} catch {
382-
log(
383-
`Invalid tooltip target selector "${targetSelector}" for message ${currentMessage.messageId}`
384-
);
385-
}
373+
const targetFound = !!targetSelector && !!findElement(targetSelector);
386374
if (!targetFound) {
387375
log(
388376
`Tooltip target not found for "${targetSelector}", emitting error and skipping display`
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import type { DisplaySettings, GistMessage, StepDisplayConfig } from '../types';
3+
4+
const mockGist = vi.hoisted(() => ({
5+
currentMessages: [] as GistMessage[],
6+
dismissMessage: vi.fn(() => Promise.resolve()),
7+
}));
8+
9+
vi.mock('../gist', () => ({ default: mockGist }));
10+
vi.mock('../utilities/log', () => ({ log: vi.fn() }));
11+
vi.mock('./message-manager', () => ({
12+
applyMessageStepChange: vi.fn(),
13+
hideMessageVisually: vi.fn(),
14+
}));
15+
vi.mock('./message-component-manager', () => ({
16+
sendDisplaySettingsToIframe: vi.fn(),
17+
}));
18+
vi.mock('../utilities/message-utils', () => ({
19+
hasDisplayChanged: vi.fn(() => false),
20+
wideOverlayPositions: ['x-gist-top-full', 'x-gist-bottom-full'],
21+
mapOverlayPositionToElementId: vi.fn((pos: string) => pos),
22+
}));
23+
vi.mock('./preview-bar-styles', () => ({
24+
PREVIEW_BAR_CSS: '.gist-pb-toggle-btn { color: white; }',
25+
chevronSvg: vi.fn(() => '<svg></svg>'),
26+
}));
27+
vi.mock('../services/preview-service', () => ({
28+
savePreviewDisplaySettings: vi.fn(() => Promise.resolve({ status: 200 })),
29+
deletePreviewSession: vi.fn(() => Promise.resolve()),
30+
}));
31+
vi.mock('../utilities/preview-mode', () => ({
32+
PREVIEW_PARAM_ID: 'cioPreviewId',
33+
teardownPreview: vi.fn(),
34+
}));
35+
36+
import {
37+
initPreviewBar,
38+
updatePreviewBarMessage,
39+
updatePreviewBarStep,
40+
destroyPreviewBar,
41+
} from './preview-bar-manager';
42+
43+
describe('preview-bar-manager', () => {
44+
beforeEach(() => {
45+
vi.clearAllMocks();
46+
document.body.innerHTML = '';
47+
mockGist.currentMessages = [];
48+
});
49+
50+
afterEach(() => {
51+
destroyPreviewBar();
52+
});
53+
54+
function initBarWithMessage(
55+
steps: StepDisplayConfig[] = [],
56+
displayType: DisplaySettings['displayType'] = 'modal'
57+
): GistMessage {
58+
initPreviewBar();
59+
const message: GistMessage = {
60+
messageId: 'msg-1',
61+
instanceId: 'inst-1',
62+
displaySettings: steps.length > 0 ? (steps as unknown as DisplaySettings) : { displayType },
63+
};
64+
mockGist.currentMessages = [message];
65+
updatePreviewBarMessage(message);
66+
return message;
67+
}
68+
69+
describe('initPreviewBar', () => {
70+
it('creates the preview bar element in the DOM', () => {
71+
initPreviewBar();
72+
expect(document.getElementById('gist-preview-bar')).not.toBeNull();
73+
});
74+
75+
it('injects preview bar styles', () => {
76+
initPreviewBar();
77+
expect(document.getElementById('gist-pb-styles')).not.toBeNull();
78+
});
79+
80+
it('does not duplicate the bar on repeated calls', () => {
81+
initPreviewBar();
82+
initPreviewBar();
83+
expect(document.querySelectorAll('#gist-preview-bar').length).toBe(1);
84+
});
85+
});
86+
87+
describe('display type dropdown', () => {
88+
it('includes tooltip option in display type dropdown', () => {
89+
initBarWithMessage();
90+
const bar = document.getElementById('gist-preview-bar')!;
91+
const select = bar.querySelector<HTMLSelectElement>('.gist-pb-select');
92+
expect(select).not.toBeNull();
93+
94+
const options = Array.from(select!.options).map((o) => o.value);
95+
expect(options).toContain('tooltip');
96+
});
97+
98+
it('includes all four display types: modal, overlay, inline, tooltip', () => {
99+
initBarWithMessage();
100+
const bar = document.getElementById('gist-preview-bar')!;
101+
const selects = bar.querySelectorAll<HTMLSelectElement>('.gist-pb-select');
102+
const displayTypeSelect = selects[0];
103+
const values = Array.from(displayTypeSelect.options).map((o) => o.value);
104+
105+
expect(values).toEqual(['modal', 'overlay', 'inline', 'tooltip']);
106+
});
107+
});
108+
109+
describe('tooltip controls', () => {
110+
it('renders element selector and position controls when display type is tooltip', () => {
111+
initBarWithMessage([], 'tooltip');
112+
const bar = document.getElementById('gist-preview-bar')!;
113+
const labels = Array.from(bar.querySelectorAll('.gist-pb-label')).map((el) => el.textContent);
114+
115+
expect(labels).toContain('Element Selector');
116+
expect(labels).toContain('Position');
117+
});
118+
119+
it('renders tooltip position options: top, bottom, left, right', () => {
120+
initBarWithMessage([], 'tooltip');
121+
const bar = document.getElementById('gist-preview-bar')!;
122+
const selects = bar.querySelectorAll<HTMLSelectElement>('.gist-pb-select');
123+
const positionSelect = Array.from(selects).find((s) =>
124+
Array.from(s.options).some((o) => o.value === 'left')
125+
);
126+
expect(positionSelect).not.toBeUndefined();
127+
128+
const values = Array.from(positionSelect!.options).map((o) => o.value);
129+
expect(values).toEqual(['top', 'bottom', 'left', 'right']);
130+
});
131+
132+
it('renders Select Element button', () => {
133+
initBarWithMessage([], 'tooltip');
134+
const bar = document.getElementById('gist-preview-bar')!;
135+
const selectBtn = bar.querySelector('.gist-pb-select-elem-btn');
136+
expect(selectBtn).not.toBeNull();
137+
expect(selectBtn!.textContent).toBe('Select Element');
138+
});
139+
140+
it('renders element selector input with placeholder text', () => {
141+
initBarWithMessage([], 'tooltip');
142+
const bar = document.getElementById('gist-preview-bar')!;
143+
const input = bar.querySelector<HTMLInputElement>('.gist-pb-input[type="text"]');
144+
expect(input).not.toBeNull();
145+
expect(input!.placeholder).toBe('Element ID or selector');
146+
});
147+
});
148+
149+
describe('inline controls', () => {
150+
it('renders element selector for inline display type', () => {
151+
initBarWithMessage([], 'inline');
152+
const bar = document.getElementById('gist-preview-bar')!;
153+
const labels = Array.from(bar.querySelectorAll('.gist-pb-label')).map((el) => el.textContent);
154+
155+
expect(labels).toContain('Element Selector');
156+
});
157+
158+
it('does not render position dropdown for inline type', () => {
159+
initBarWithMessage([], 'inline');
160+
const bar = document.getElementById('gist-preview-bar')!;
161+
const labels = Array.from(bar.querySelectorAll('.gist-pb-label')).map((el) => el.textContent);
162+
163+
expect(labels).not.toContain('Position');
164+
});
165+
});
166+
167+
describe('button type attributes', () => {
168+
it('toggle button has type="button"', () => {
169+
initBarWithMessage();
170+
const bar = document.getElementById('gist-preview-bar')!;
171+
const toggleBtn = bar.querySelector<HTMLButtonElement>('.gist-pb-toggle-btn');
172+
expect(toggleBtn).not.toBeNull();
173+
expect(toggleBtn!.type).toBe('button');
174+
});
175+
176+
it('end session button has type="button"', () => {
177+
initBarWithMessage();
178+
const bar = document.getElementById('gist-preview-bar')!;
179+
const endBtn = bar.querySelector<HTMLButtonElement>('.gist-pb-save-btn');
180+
expect(endBtn).not.toBeNull();
181+
expect(endBtn!.type).toBe('button');
182+
});
183+
184+
it('select element button has type="button"', () => {
185+
initBarWithMessage([], 'tooltip');
186+
const bar = document.getElementById('gist-preview-bar')!;
187+
const selectBtn = bar.querySelector<HTMLButtonElement>('.gist-pb-select-elem-btn');
188+
expect(selectBtn).not.toBeNull();
189+
expect(selectBtn!.type).toBe('button');
190+
});
191+
});
192+
193+
describe('updatePreviewBarStep', () => {
194+
it('updates the bar when step changes', () => {
195+
const steps: StepDisplayConfig[] = [
196+
{ stepName: 'step-1', displaySettings: { displayType: 'modal' } },
197+
{
198+
stepName: 'step-2',
199+
displaySettings: { displayType: 'tooltip', tooltipPosition: 'bottom' },
200+
},
201+
];
202+
initBarWithMessage(steps);
203+
204+
updatePreviewBarStep('step-2', { displayType: 'tooltip', tooltipPosition: 'bottom' });
205+
206+
const bar = document.getElementById('gist-preview-bar')!;
207+
const labels = Array.from(bar.querySelectorAll('.gist-pb-label')).map((el) => el.textContent);
208+
expect(labels).toContain('Element Selector');
209+
expect(labels).toContain('Position');
210+
});
211+
});
212+
213+
describe('destroyPreviewBar', () => {
214+
it('removes the bar and styles from the DOM', () => {
215+
initPreviewBar();
216+
expect(document.getElementById('gist-preview-bar')).not.toBeNull();
217+
expect(document.getElementById('gist-pb-styles')).not.toBeNull();
218+
219+
destroyPreviewBar();
220+
221+
expect(document.getElementById('gist-preview-bar')).toBeNull();
222+
expect(document.getElementById('gist-pb-styles')).toBeNull();
223+
});
224+
});
225+
});

0 commit comments

Comments
 (0)