import {createMap as globalCreateMap, beforeMapTest} from '../util/test/util';
import {Popup, Offset} from './popup';
import {LngLat} from '../geo/lng_lat';
import Point from '@mapbox/point-geometry';
import simulate from '../../test/unit/lib/simulate_interaction';
import {PositionAnchor} from './anchor';
const containerWidth = 512;
const containerHeight = 512;
function createMap(options?) {
options = options || {};
const container = window.document.createElement('div');
window.document.body.appendChild(container);
Object.defineProperty(container, 'clientWidth', {value: options.width || containerWidth});
Object.defineProperty(container, 'clientHeight', {value: options.height || containerHeight});
return globalCreateMap({...options, container});
}
beforeEach(() => {
beforeMapTest();
});
describe('popup', () => {
test('Popup#getElement returns a .maplibregl-popup element', () => {
const map = createMap();
const popup = new Popup()
.setText('Test')
.setLngLat([0, 0])
.addTo(map);
expect(popup.isOpen()).toBeTruthy();
expect(popup.getElement().classList.contains('maplibregl-popup')).toBeTruthy();
});
test('Popup#addTo adds a .maplibregl-popup element', () => {
const map = createMap();
const popup = new Popup()
.setText('Test')
.setLngLat([0, 0])
.addTo(map);
expect(popup.isOpen()).toBeTruthy();
expect(map.getContainer().querySelectorAll('.maplibregl-popup')).toHaveLength(1);
});
test('Popup closes on map click events by default', () => {
const map = createMap();
const popup = new Popup()
.setText('Test')
.setLngLat([0, 0])
.addTo(map);
simulate.click(map.getCanvas());
expect(!popup.isOpen()).toBeTruthy();
});
test('Popup does not close on map click events when the closeOnClick option is false', () => {
const map = createMap();
const popup = new Popup({closeOnClick: false})
.setText('Test')
.setLngLat([0, 0])
.addTo(map);
simulate.click(map.getCanvas());
expect(popup.isOpen()).toBeTruthy();
});
test('Popup closes on close button click events', () => {
const map = createMap();
const popup = new Popup()
.setText('Test')
.setLngLat([0, 0])
.addTo(map);
simulate.click(map.getContainer().querySelector('.maplibregl-popup-close-button'));
expect(!popup.isOpen()).toBeTruthy();
});
test('Popup has no close button if closeButton option is false', () => {
const map = createMap();
const popup = new Popup({closeButton: false})
.setText('Test')
.setLngLat([0, 0])
.addTo(map);
expect(
popup.getElement().querySelectorAll('.maplibregl-popup-close-button')
).toHaveLength(0);
});
test('Popup does not close on map move events when the closeOnMove option is false', () => {
const map = createMap();
const popup = new Popup({closeOnMove: false})
.setText('Test')
.setLngLat([0, 0])
.addTo(map);
map.setCenter([-10, 0]); // longitude bounds: [-370, 350]
expect(popup.isOpen()).toBeTruthy();
});
test('Popup closes on map move events when the closeOnMove option is true', () => {
const map = createMap();
const popup = new Popup({closeOnMove: true})
.setText('Test')
.setLngLat([0, 0])
.addTo(map);
map.setCenter([-10, 0]); // longitude bounds: [-370, 350]
expect(!popup.isOpen()).toBeTruthy();
});
test('Popup fires close event when removed', () => {
const map = createMap();
const onClose = jest.fn();
new Popup()
.setText('Test')
.setLngLat([0, 0])
.on('close', onClose)
.addTo(map)
.remove();
expect(onClose).toHaveBeenCalled();
});
test('Popup does not fire close event when removed if it is not on the map', () => {
const onClose = jest.fn();
new Popup()
.setText('Test')
.setLngLat([0, 0])
.on('close', onClose)
.remove();
expect(onClose).not.toHaveBeenCalled();
});
test('Popup fires open event when added', () => {
const map = createMap();
const onOpen = jest.fn();
new Popup()
.setText('Test')
.setLngLat([0, 0])
.on('open', onOpen)
.addTo(map);
expect(onOpen).toHaveBeenCalled();
});
test('Popup content can be set via setText', () => {
const map = createMap();
const popup = new Popup({closeButton: false})
.setLngLat([0, 0])
.addTo(map)
.setText('Test');
expect(popup.getElement().textContent).toBe('Test');
});
test('Popup content can be set via setHTML', () => {
const map = createMap();
const popup = new Popup({closeButton: false})
.setLngLat([0, 0])
.addTo(map)
.setHTML('Test');
expect(popup.getElement().querySelector('.maplibregl-popup-content').innerHTML).toBe('Test');
});
test('Popup width maximum defaults to 240px', () => {
const map = createMap();
const popup = new Popup({closeButton: false})
.setLngLat([0, 0])
.addTo(map)
.setHTML('Test');
expect(popup.getMaxWidth()).toBe('240px');
});
test('Popup width maximum can be set via using maxWidth option', () => {
const map = createMap();
const popup = new Popup({closeButton: false, maxWidth: '5px'})
.setLngLat([0, 0])
.addTo(map)
.setHTML('Test');
expect(popup.getMaxWidth()).toBe('5px');
});
test('Popup width maximum can be set via maxWidth', () => {
const map = createMap();
const popup = new Popup({closeButton: false})
.setLngLat([0, 0])
.setHTML('Test')
.setMaxWidth('5px')
.addTo(map);
expect(popup.getMaxWidth()).toBe('5px');
});
test('Popup content can be set via setDOMContent', () => {
const map = createMap();
const content = window.document.createElement('span');
const popup = new Popup({closeButton: false})
.setLngLat([0, 0])
.addTo(map)
.setDOMContent(content);
expect(popup.getElement().querySelector('.maplibregl-popup-content').firstChild).toBe(content);
});
test('Popup#setText protects against XSS', () => {
const map = createMap();
const popup = new Popup({closeButton: false})
.setLngLat([0, 0])
.addTo(map)
.setText('');
expect(popup.getElement().textContent).toBe('');
});
test('Popup content setters overwrite previous content', () => {
const map = createMap();
const popup = new Popup({closeButton: false})
.setLngLat([0, 0])
.addTo(map);
popup.setText('Test 1');
expect(popup.getElement().textContent).toBe('Test 1');
popup.setHTML('Test 2');
expect(popup.getElement().textContent).toBe('Test 2');
popup.setDOMContent(window.document.createTextNode('Test 3'));
expect(popup.getElement().textContent).toBe('Test 3');
});
test('Popup provides LngLat accessors', () => {
expect(new Popup().getLngLat()).toBeUndefined();
expect(new Popup().setLngLat([1, 2]).getLngLat() instanceof LngLat).toBeTruthy();
expect(new Popup().setLngLat([1, 2]).getLngLat()).toEqual(new LngLat(1, 2));
expect(new Popup().setLngLat(new LngLat(1, 2)).getLngLat() instanceof LngLat).toBeTruthy();
expect(new Popup().setLngLat(new LngLat(1, 2)).getLngLat()).toEqual(new LngLat(1, 2));
});
test('Popup is positioned at the specified LngLat in a world copy', () => {
const map = createMap({width: 1024}); // longitude bounds: [-360, 360]
const popup = new Popup()
.setLngLat([270, 0])
.setText('Test')
.addTo(map);
expect(popup._pos).toEqual(map.project([270, 0]));
});
test('Popup preserves object constancy of position after map move', () => {
const map = createMap({width: 1024}); // longitude bounds: [-360, 360]
const popup = new Popup()
.setLngLat([270, 0])
.setText('Test')
.addTo(map);
map.setCenter([-10, 0]); // longitude bounds: [-370, 350]
expect(popup._pos).toEqual(map.project([270, 0]));
map.setCenter([-20, 0]); // longitude bounds: [-380, 340]
expect(popup._pos).toEqual(map.project([270, 0]));
});
test('Popup preserves object constancy of position after auto-wrapping center (left)', () => {
const map = createMap({width: 1024});
map.setCenter([-175, 0]); // longitude bounds: [-535, 185]
const popup = new Popup()
.setLngLat([0, 0])
.setText('Test')
.addTo(map);
map.setCenter([175, 0]); // longitude bounds: [-185, 535]
expect(popup._pos).toEqual(map.project([360, 0]));
});
test('Popup preserves object constancy of position after auto-wrapping center (right)', () => {
const map = createMap({width: 1024});
map.setCenter([175, 0]); // longitude bounds: [-185, 535]
const popup = new Popup()
.setLngLat([0, 0])
.setText('Test')
.addTo(map);
map.setCenter([-175, 0]); // longitude bounds: [-185, 535]
expect(popup._pos).toEqual(map.project([-360, 0]));
});
test('Popup wraps position after map move if it would otherwise go offscreen (right)', () => {
const map = createMap({width: 1024}); // longitude bounds: [-360, 360]
const popup = new Popup()
.setLngLat([-355, 0])
.setText('Test')
.addTo(map);
map.setCenter([10, 0]); // longitude bounds: [-350, 370]
expect(popup._pos).toEqual(map.project([5, 0]));
});
test('Popup wraps position after map move if it would otherwise go offscreen (right)', () => {
const map = createMap({width: 1024}); // longitude bounds: [-360, 360]
const popup = new Popup()
.setLngLat([355, 0])
.setText('Test')
.addTo(map);
map.setCenter([-10, 0]); // longitude bounds: [-370, 350]
expect(popup._pos).toEqual(map.project([-5, 0]));
});
test('Popup is repositioned at the specified LngLat', () => {
const map = createMap({width: 1024}); // longitude bounds: [-360, 360]
map.terrain = {
getElevationForLngLatZoom: () => 0
} as any;
const popup = new Popup()
.setLngLat([70, 0])
.setText('Test')
.addTo(map)
.setLngLat([0, 0]);
expect(popup._pos).toEqual(map.project([0, 0]));
});
test('Popup anchors as specified by the anchor option', () => {
const map = createMap();
const popup = new Popup({anchor: 'top-left'})
.setLngLat([0, 0])
.setText('Test')
.addTo(map);
expect(popup.getElement().classList.contains('maplibregl-popup-anchor-top-left')).toBeTruthy();
});
([
['top-left', new Point(10, 10), 'translate(0,0) translate(7px,7px)'],
['top', new Point(containerWidth / 2, 10), 'translate(-50%,0) translate(0px,10px)'],
['top-right', new Point(containerWidth - 10, 10), 'translate(-100%,0) translate(-7px,7px)'],
['right', new Point(containerWidth - 10, containerHeight / 2), 'translate(-100%,-50%) translate(-10px,0px)'],
['bottom-right', new Point(containerWidth - 10, containerHeight - 10), 'translate(-100%,-100%) translate(-7px,-7px)'],
['bottom', new Point(containerWidth / 2, containerHeight - 10), 'translate(-50%,-100%) translate(0px,-10px)'],
['bottom-left', new Point(10, containerHeight - 10), 'translate(0,-100%) translate(7px,-7px)'],
['left', new Point(10, containerHeight / 2), 'translate(0,-50%) translate(10px,0px)'],
['bottom', new Point(containerWidth / 2, containerHeight / 2), 'translate(-50%,-100%) translate(0px,-10px)']
] as [PositionAnchor, Point, string][]).forEach((args) => {
const anchor = args[0];
const point = args[1];
const transform = args[2];
test(`Popup automatically anchors to ${anchor}`, () => {
const map = createMap();
const popup = new Popup()
.setLngLat([0, 0])
.setText('Test')
.addTo(map);
Object.defineProperty(popup.getElement(), 'offsetWidth', {value: 100});
Object.defineProperty(popup.getElement(), 'offsetHeight', {value: 100});
jest.spyOn(map, 'project').mockReturnValue(point);
popup.setLngLat([0, 0]);
expect(popup.getElement().classList.contains(`maplibregl-popup-anchor-${anchor}`)).toBeTruthy();
});
test(`Popup translation reflects offset and ${anchor} anchor`, () => {
const map = createMap();
jest.spyOn(map, 'project').mockReturnValue(new Point(0, 0));
const popup = new Popup({anchor, offset: 10})
.setLngLat([0, 0])
.setText('Test')
.addTo(map);
expect(popup.getElement().style.transform).toBe(transform);
});
});
test('Popup automatically anchors to top if its bottom offset would push it off-screen', () => {
const map = createMap();
const point = new Point(containerWidth / 2, containerHeight / 2);
const options = {offset: {
'bottom': [0, -25],
'top': [0, 0]
} as Offset};
const popup = new Popup(options)
.setLngLat([0, 0])
.setText('Test')
.addTo(map);
Object.defineProperty(popup.getElement(), 'offsetWidth', {value: containerWidth / 2});
Object.defineProperty(popup.getElement(), 'offsetHeight', {value: containerHeight / 2});
jest.spyOn(map, 'project').mockReturnValue(point);
popup.setLngLat([0, 0]);
expect(popup.getElement().classList.contains('maplibregl-popup-anchor-top')).toBeTruthy();
});
test('Popup is offset via a PointLike offset option', () => {
const map = createMap();
jest.spyOn(map, 'project').mockReturnValue(new Point(0, 0));
const popup = new Popup({anchor: 'top-left', offset: [5, 10]})
.setLngLat([0, 0])
.setText('Test')
.addTo(map);
expect(popup.getElement().style.transform).toBe('translate(0,0) translate(5px,10px)');
});
test('Popup is offset via an object offset option', () => {
const map = createMap();
jest.spyOn(map, 'project').mockReturnValue(new Point(0, 0));
const popup = new Popup({anchor: 'top-left', offset: {'top-left': [5, 10]} as Offset})
.setLngLat([0, 0])
.setText('Test')
.addTo(map);
expect(popup.getElement().style.transform).toBe('translate(0,0) translate(5px,10px)');
});
test('Popup is offset via an incomplete object offset option', () => {
const map = createMap();
jest.spyOn(map, 'project').mockReturnValue(new Point(0, 0));
const popup = new Popup({anchor: 'top-right', offset: {'top-left': [5, 10]} as Offset})
.setLngLat([0, 0])
.setText('Test')
.addTo(map);
expect(popup.getElement().style.transform).toBe('translate(-100%,0) translate(0px,0px)');
});
test('Popup offset can be set via setOffset', () => {
const map = createMap();
const popup = new Popup({offset: 5})
.setLngLat([0, 0])
.setText('Test')
.addTo(map);
expect(popup.options.offset).toBe(5);
popup.setOffset(10);
expect(popup.options.offset).toBe(10);
});
test('Popup can be removed and added again (#1477)', () => {
const map = createMap();
new Popup()
.setText('Test')
.setLngLat([0, 0])
.addTo(map)
.remove()
.addTo(map);
expect(map.getContainer().querySelectorAll('.maplibregl-popup')).toHaveLength(1);
});
test('Popup#addTo is idempotent (#1811)', () => {
const map = createMap();
const popup = new Popup({closeButton: false})
.setText('Test')
.setLngLat([0, 0])
.addTo(map)
.addTo(map);
expect(popup.getElement().querySelector('.maplibregl-popup-content').textContent).toBe('Test');
});
test('Popup#remove is idempotent (#2395)', () => {
const map = createMap();
new Popup({closeButton: false})
.setText('Test')
.setLngLat([0, 0])
.addTo(map)
.remove()
.remove();
expect(map.getContainer().querySelectorAll('.maplibregl-popup')).toHaveLength(0);
});
test('Popup adds classes from className option, methods for class manipulations works properly', () => {
const map = createMap();
const popup = new Popup({className: 'some classes'})
.setText('Test')
.setLngLat([0, 0])
.addTo(map);
const popupContainer = popup.getElement();
expect(popupContainer.classList.contains('some')).toBeTruthy();
expect(popupContainer.classList.contains('classes')).toBeTruthy();
const addClassNameMethodPopupInstance = popup.addClassName('addedClass');
expect(popupContainer.classList.contains('addedClass')).toBeTruthy();
expect(addClassNameMethodPopupInstance).toBeInstanceOf(Popup);
const removeClassNameMethodPopupInstance = popup.removeClassName('addedClass');
expect(!popupContainer.classList.contains('addedClass')).toBeTruthy();
expect(removeClassNameMethodPopupInstance).toBeInstanceOf(Popup);
popup.toggleClassName('toggle');
expect(popupContainer.classList.contains('toggle')).toBeTruthy();
popup.toggleClassName('toggle');
expect(!popupContainer.classList.contains('toggle')).toBeTruthy();
expect(() => popup.addClassName('should throw exception')).toThrow(window.DOMException);
expect(() => popup.removeClassName('should throw exception')).toThrow(window.DOMException);
expect(() => popup.toggleClassName('should throw exception')).toThrow(window.DOMException);
expect(() => popup.addClassName('')).toThrow(window.DOMException);
expect(() => popup.removeClassName('')).toThrow(window.DOMException);
expect(() => popup.toggleClassName('')).toThrow(window.DOMException);
});
test('Cursor-tracked popup disappears on mouseout', () => {
const map = createMap();
const popup = new Popup()
.setText('Test')
.trackPointer()
.addTo(map);
expect(popup._trackPointer).toBe(true);
});
test('Pointer-tracked popup is tagged with right class', () => {
const map = createMap();
const popup = new Popup()
.setText('Test')
.trackPointer()
.addTo(map);
expect(
popup._container.classList.value
).toContain('maplibregl-popup-track-pointer');
});
test('Pointer-tracked popup with content set later is tagged with right class ', () => {
const map = createMap();
const popup = new Popup()
.trackPointer()
.addTo(map);
popup.setText('Test');
expect(
popup._container.classList.value
).toContain('maplibregl-popup-track-pointer');
});
test('Pointer-tracked popup that is set afterwards is tagged with right class ', () => {
const map = createMap();
const popup = new Popup()
.addTo(map);
popup.setText('Test');
popup.trackPointer();
expect(
popup._container.classList.value
).toContain('maplibregl-popup-track-pointer');
});
test('Pointer-tracked popup can be repositioned with setLngLat', () => {
const map = createMap();
const popup = new Popup()
.setText('Test')
.trackPointer()
.setLngLat([0, 0])
.addTo(map);
expect(popup._pos).toEqual(map.project([0, 0]));
expect(
popup._container.classList.value
).not.toContain('maplibregl-popup-track-pointer');
expect(
map._canvasContainer.classList.value
).not.toContain('maplibregl-track-pointer');
});
test('Pointer-tracked popup calling Popup#remove removes track-pointer class from map (#3434)', () => {
const map = createMap();
new Popup()
.setText('Test')
.trackPointer()
.addTo(map)
.remove();
expect(
map._canvasContainer.classList.value
).not.toContain('maplibregl-track-pointer');
});
test('Positioned popup lacks pointer-tracking class', () => {
const map = createMap();
const popup = new Popup()
.setText('Test')
.setLngLat([0, 0])
.addTo(map);
expect(
popup._container.classList.value
).not.toContain('maplibregl-popup-track-pointer');
});
test('Positioned popup can be set to track pointer', () => {
const map = createMap();
const popup = new Popup()
.setText('Test')
.setLngLat([0, 0])
.trackPointer()
.addTo(map);
simulate.mousemove(map.getCanvas(), {screenX: 0, screenY: 0});
expect(popup._pos).toEqual({x: 0, y: 0});
});
test('Popup closes on Map#remove', () => {
const map = createMap();
const popup = new Popup()
.setText('Test')
.setLngLat([0, 0])
.addTo(map);
map.remove();
expect(!popup.isOpen()).toBeTruthy();
});
test('Adding popup with no focusable content (Popup#setText) does not change the active element', () => {
const dummyFocusedEl = window.document.createElement('button');
window.document.body.appendChild(dummyFocusedEl);
dummyFocusedEl.focus();
new Popup({closeButton: false})
.setText('Test')
.setLngLat([0, 0])
.addTo(createMap());
expect(window.document.activeElement).toBe(dummyFocusedEl);
});
test('Adding popup with no focusable content (Popup#setHTML) does not change the active element', () => {
const dummyFocusedEl = window.document.createElement('button');
window.document.body.appendChild(dummyFocusedEl);
dummyFocusedEl.focus();
new Popup({closeButton: false})
.setHTML('Test')
.setLngLat([0, 0])
.addTo(createMap());
expect(window.document.activeElement).toBe(dummyFocusedEl);
});
test('Close button is focused if it is the only focusable element', () => {
const dummyFocusedEl = window.document.createElement('button');
window.document.body.appendChild(dummyFocusedEl);
dummyFocusedEl.focus();
const popup = new Popup({closeButton: true})
.setHTML('Test')
.setLngLat([0, 0])
.addTo(createMap({
locale: {
'Popup.Close': 'Alt close label'
}
}));
// Suboptimal because the string matching is case-sensitive
const closeButton = popup._container.querySelector('[aria-label^=\'Alt close label\']');
expect(window.document.activeElement).toBe(closeButton);
});
test('If popup content contains a focusable element it is focused', () => {
const popup = new Popup({closeButton: true})
.setHTML('Test')
.setLngLat([0, 0])
.addTo(createMap());
const focusableEl = popup._container.querySelector('[data-testid=\'abc\']');
expect(window.document.activeElement).toBe(focusableEl);
});
test('Element with tabindex="-1" is not focused', () => {
const popup = new Popup({closeButton: true})
.setHTML('Test')
.setLngLat([0, 0])
.addTo(createMap());
const nonFocusableEl = popup._container.querySelector('[data-testid=\'abc\']');
const closeButton = popup._container.querySelector('button[aria-label=\'Close popup\']');
expect(window.document.activeElement).not.toBe(nonFocusableEl);
expect(window.document.activeElement).toBe(closeButton);
});
test('If popup contains a disabled button and a focusable element then the latter is focused', () => {
const popup = new Popup({closeButton: true})
.setHTML(`
`)
.setLngLat([0, 0])
.addTo(createMap());
const focusableEl = popup._container.querySelector('[data-testid=\'abc\']');
expect(window.document.activeElement).toBe(focusableEl);
});
test('Popup with disabled focusing does not change the active element', () => {
const dummyFocusedEl = window.document.createElement('button');
window.document.body.appendChild(dummyFocusedEl);
dummyFocusedEl.focus();
new Popup({closeButton: false, focusAfterOpen: false})
.setHTML('Test')
.setLngLat([0, 0])
.addTo(createMap());
expect(window.document.activeElement).toBe(dummyFocusedEl);
});
test('Popup is positioned on rounded whole-number pixel coordinates by default when offset is a decimal', () => {
const map = createMap();
jest.spyOn(map, 'project').mockReturnValue(new Point(0, 0));
const popup = new Popup({offset: [-0.1, 0.9]})
.setLngLat([0, 0])
.setText('foobar')
.addTo(map);
expect(popup.getElement().style.transform).toBe('translate(-50%,-100%) translate(0px,1px)');
});
test('Popup position is not rounded when subpixel positioning is enabled', () => {
const map = createMap();
jest.spyOn(map, 'project').mockReturnValue(new Point(0, 0));
const popup = new Popup({offset: [-0.1, 0.9], subpixelPositioning: true})
.setLngLat([0, 0])
.setText('foobar')
.addTo(map);
expect(popup.getElement().style.transform).toBe('translate(-50%,-100%) translate(-0.1px,0.9px)');
});
test('Popup subpixel positioning can be enabled with Popup#setSubpixelPositioning', () => {
const map = createMap();
jest.spyOn(map, 'project').mockReturnValue(new Point(0, 0));
const popup = new Popup({offset: [0, 0]})
.setLngLat([0, 0])
.setText('foobar')
.addTo(map);
popup.setSubpixelPositioning(true);
popup.setOffset([-0.1, 0.9]);
expect(popup.getElement().style.transform).toBe('translate(-50%,-100%) translate(-0.1px,0.9px)');
});
test('Popup subpixel positioning can be disabled with Popup#setSubpixelPositioning', () => {
const map = createMap();
jest.spyOn(map, 'project').mockReturnValue(new Point(0, 0));
const popup = new Popup({offset: [0, 0], subpixelPositioning: true})
.setLngLat([0, 0])
.setText('foobar')
.addTo(map);
popup.setSubpixelPositioning(false);
popup.setOffset([-0.1, 0.9]);
expect(popup.getElement().style.transform).toBe('translate(-50%,-100%) translate(0px,1px)');
});
});