Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 19.3.9
Fixed: a hotspot tooltip floated over the page while the panel around it scrolled, instead of staying next to its element. Since 19.3.7 the hotspot is absolutely positioned so it does not shift the element's layout; inside a scrolling panel without a positioned ancestor it was anchored outside the panel. The hotspot now uses its element as the anchor and scrolls with it.

## 19.3.8
Fixed: `Gleap.identify` with a `userHash` sent nothing when the browser already held a contact identified with the same user id and data without a hash (identified before identity verification was switched on, or before the app passed a hash). The server never received the hash, so the contact was never marked as verified. The SDK now sends the hashed identify once per contact and browser and skips unchanged identifies again afterwards. Only the user id is remembered, never the hash; logging out with `Gleap.clearIdentity()` clears it.

Expand Down
2 changes: 1 addition & 1 deletion build/browser/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/browser/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/cjs/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/cjs/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/esm/index.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/esm/index.mjs.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"description": "JavaScript and TypeScript SDK for Gleap, an Intercom alternative with AI customer support, live chat, in-app bug reporting and customer feedback.",
"name": "gleap",
"version": "19.3.8",
"version": "19.3.9",
"main": "build/cjs/index.js",
"module": "build/esm/index.mjs",
"exports": {
Expand Down
2 changes: 2 additions & 0 deletions published/19.3.9/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions published/19.3.9/index.js.map

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions published/19.3.9/survey-embed.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion published/latest/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion published/latest/index.js.map

Large diffs are not rendered by default.

71 changes: 71 additions & 0 deletions src/GleapTooltipManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export default class GleapTooltipManager {
if (link.anchor) {
link.anchor.remove();
}
this.restoreHost(link.positionedHost);
this.elementToFloatingUIMap.delete(element);
}

Expand Down Expand Up @@ -375,6 +376,7 @@ export default class GleapTooltipManager {

var tooltipElem = null;
var hotspotContainer = null;
var positionedHost = null;

if (tooltip.mode === 'hotspot') {
// Create hotspot container. It is absolutely positioned and added as the last
Expand All @@ -399,12 +401,20 @@ export default class GleapTooltipManager {
});
element.setAttribute('data-gleap-tooltip-mode', 'hotspot');

let host = null;
if (this.canEmbed(element)) {
element.appendChild(hotspotContainer);
host = element;
} else if (element.parentNode) {
element.parentNode.insertBefore(hotspotContainer, element.nextSibling);
host = element.parentNode;
}

// The anchor is absolutely positioned, so it sits in the nearest positioned
// ancestor. When a scrolling panel lies between the element and that
// ancestor, the hotspot stays put while the panel scrolls (#227901).
positionedHost = this.positionHost(host);

// Create hotspot.
const hotspot = document.createElement('div');
hotspot.classList.add('gleap-tooltip-hotspot');
Expand All @@ -429,6 +439,7 @@ export default class GleapTooltipManager {
this.elementToFloatingUIMap.set(element, {
...floatingUIInstance,
anchor: hotspotContainer,
positionedHost,
data: tooltip,
});

Expand Down Expand Up @@ -463,6 +474,7 @@ export default class GleapTooltipManager {
try {
link.cleanup();
} catch (exp) {}
this.restoreHost(link.positionedHost);
}
});

Expand Down Expand Up @@ -513,6 +525,17 @@ export default class GleapTooltipManager {
return;
}

// A re-render may have reset the scroll container's inline style: position it again.
if (link.positionedHost && link.positionedHost.host) {
const host = link.positionedHost.host;
try {
const position = window.getComputedStyle(host).position;
if (!position || position === 'static') {
host.style.position = 'relative';
}
} catch (exp) {}
}

if (!tooltip.posX) {
tooltip.posX = 'right';
}
Expand Down Expand Up @@ -586,6 +609,54 @@ export default class GleapTooltipManager {
}
}

// Makes the first scroll container above the host (before any positioned
// ancestor) the containing block of our absolutely positioned anchor, so the
// anchor scrolls with the panel. Returns what to restore on unlink, or null
// when nothing had to change.
positionHost(host) {
let node = host;
while (node && node.nodeType === Node.ELEMENT_NODE && node !== document.documentElement) {
let style = null;
try {
style = window.getComputedStyle(node);
} catch (exp) {
return null;
}
if (style.position && style.position !== 'static') {
return null;
}
if (this.isScrollContainer(style)) {
const previous = node.style.getPropertyValue('position');
node.style.position = 'relative';
return { host: node, previous };
}
node = node.parentElement;
}
return null;
}

isScrollContainer(style) {
// The shorthand too: not every engine expands it into overflow-x/y.
const values = `${style.overflow || ''} ${style.overflowX || ''} ${style.overflowY || ''}`.split(/\s+/);
return values.some((value) => value === 'auto' || value === 'scroll' || value === 'overlay');
}

restoreHost(positionedHost) {
if (!positionedHost || !positionedHost.host) {
return;
}
const { host, previous } = positionedHost;
// Leave a position the page set itself in the meantime alone.
if (host.style.position !== 'relative') {
return;
}
if (previous) {
host.style.setProperty('position', previous);
} else {
host.style.removeProperty('position');
}
}

toOffset(value, fallback) {
if (value === null || value === undefined || value === '') {
return fallback;
Expand Down
157 changes: 157 additions & 0 deletions src/GleapTooltipManager.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* @jest-environment jsdom
*/
import GleapTooltipManager from './GleapTooltipManager';

jest.mock('./Gleap', () => ({
__esModule: true,
default: {},
GleapSession: { getInstance: jest.fn(() => ({})) },
}));

jest.mock('./UI', () => ({
loadIcon: jest.fn(() => '<svg></svg>'),
}));

jest.mock('@floating-ui/dom', () => ({
autoUpdate: jest.fn(() => jest.fn()),
computePosition: jest.fn(() => Promise.resolve({ x: 0, y: 0, middlewareData: {}, placement: 'right' })),
arrow: jest.fn(),
flip: jest.fn(),
offset: jest.fn(),
shift: jest.fn(),
}));

const hotspotTooltip = (overrides = {}) => ({
mode: 'hotspot',
selector: '.target',
html: 'Help',
icon: 'circle-question',
color: '#000',
...overrides,
});

describe('hotspot anchor inside a scrolling panel (#227901)', () => {
let manager;

beforeEach(() => {
document.body.innerHTML = '';
manager = new GleapTooltipManager();
});

test('the scroll container becomes the containing block of the anchor, the element is left alone', () => {
document.body.innerHTML =
'<div class="app" style="position: relative"><div class="panel" style="overflow: auto">' +
'<div class="wrap"><h3 class="target">Workspace access</h3></div></div></div>';
const element = document.querySelector('.target');

manager.linkTooltip(element, hotspotTooltip());

expect(document.querySelector('.panel').style.position).toBe('relative');
expect(element.style.position).toBe('');
expect(document.querySelector('.wrap').style.position).toBe('');
expect(document.querySelector('.app').style.position).toBe('relative');
const anchor = element.querySelector('[data-gleap-tooltip-anchor]');
expect(anchor).not.toBeNull();
expect(anchor.style.position).toBe('absolute');
});

test('a positioned ancestor inside the panel means nothing has to change', () => {
document.body.innerHTML =
'<div class="panel" style="overflow: auto"><div class="card" style="position: relative">' +
'<h3 class="target">Title</h3></div></div>';

manager.linkTooltip(document.querySelector('.target'), hotspotTooltip());

expect(document.querySelector('.panel').style.position).toBe('');
expect(document.querySelector('.card').style.position).toBe('relative');
});

test('a panel that is positioned already is left alone', () => {
document.body.innerHTML = '<div class="panel" style="overflow: scroll; position: sticky"><h3 class="target">Title</h3></div>';

manager.linkTooltip(document.querySelector('.target'), hotspotTooltip());

expect(document.querySelector('.panel').style.position).toBe('sticky');
});

test('without a scroll container nothing is touched (the window scrolls the anchor)', () => {
document.body.innerHTML = '<div class="wrap" style="overflow: hidden"><h3 class="target">Title</h3></div>';
const element = document.querySelector('.target');

manager.linkTooltip(element, hotspotTooltip());

expect(element.style.position).toBe('');
expect(document.querySelector('.wrap').style.position).toBe('');
expect(document.body.style.position).toBe('');
});

test('next to a void element the search starts at its parent', () => {
document.body.innerHTML = '<div class="panel" style="overflow-y: auto"><label class="field"><input class="target" /></label></div>';

manager.linkTooltip(document.querySelector('.target'), hotspotTooltip());

expect(document.querySelector('.panel').style.position).toBe('relative');
expect(document.querySelector('.field').style.position).toBe('');
});

test('unlinking restores the previous inline position', () => {
document.body.innerHTML =
'<div class="a" style="overflow: auto"><h3 class="target">Title</h3></div>' +
'<div class="b" style="overflow: auto; position: static"><h3 class="other">Other</h3></div>';
const element = document.querySelector('.target');
const other = document.querySelector('.other');
const panelA = document.querySelector('.a');
const panelB = document.querySelector('.b');

manager.linkTooltip(element, hotspotTooltip());
manager.linkTooltip(other, hotspotTooltip({ selector: '.other' }));
expect(panelA.style.position).toBe('relative');
expect(panelB.style.position).toBe('relative');

manager.unlinkElement(element);
manager.unlinkElement(other);

expect(panelA.style.position).toBe('');
expect(panelB.style.position).toBe('static');
expect(element.querySelector('[data-gleap-tooltip-anchor]')).toBeNull();
});

test('unlinking leaves a position the page set in the meantime alone', () => {
document.body.innerHTML = '<div class="panel" style="overflow: auto"><h3 class="target">Title</h3></div>';
const panel = document.querySelector('.panel');

manager.linkTooltip(document.querySelector('.target'), hotspotTooltip());
panel.style.position = 'sticky';
manager.unlinkElement(document.querySelector('.target'));

expect(panel.style.position).toBe('sticky');
});

test('repositioning puts the position back after a re-render reset it', () => {
document.body.innerHTML = '<div class="panel" style="overflow: auto"><h3 class="target">Title</h3></div>';
const element = document.querySelector('.target');
const panel = document.querySelector('.panel');
const tooltip = hotspotTooltip();

manager.linkTooltip(element, tooltip);
panel.style.position = '';

manager.repositionHotspot(element, tooltip);

expect(panel.style.position).toBe('relative');
});

test('destroy restores the panel', () => {
document.body.innerHTML = '<div class="panel" style="overflow: auto"><h3 class="target">Title</h3></div>';
const panel = document.querySelector('.panel');

manager.linkTooltip(document.querySelector('.target'), hotspotTooltip());
expect(panel.style.position).toBe('relative');

manager.destroy();

expect(panel.style.position).toBe('');
expect(document.querySelector('[data-gleap-tooltip-anchor]')).toBeNull();
});
});
Loading