diff --git a/src/ui/popup.js b/src/ui/popup.js index 1e11ede66af..fa7482c928e 100644 --- a/src/ui/popup.js +++ b/src/ui/popup.js @@ -2,7 +2,7 @@ import {extend, bindAll} from '../util/util.js'; import {Event, Evented} from '../util/evented.js'; -import {MapMouseEvent} from '../ui/events.js'; +import {MapMouseEvent} from './events.js'; import * as DOM from '../util/dom.js'; import LngLat from '../geo/lng_lat.js'; import Point from '@mapbox/point-geometry'; @@ -19,7 +19,7 @@ const defaultOptions = { closeOnClick: true, focusAfterOpen: true, className: '', - maxWidth: "240px" + maxWidth: "240px", }; export type Offset = number | PointLike | {[_: Anchor]: PointLike}; @@ -32,7 +32,8 @@ export type PopupOptions = { anchor?: Anchor, offset?: Offset, className?: string, - maxWidth?: string + maxWidth?: string, + fixedAnchor: boolean }; const focusQuerySelector = [ @@ -57,6 +58,9 @@ const focusQuerySelector = [ * map moves. * @param {boolean} [options.focusAfterOpen=true] If `true`, the popup will try to focus the * first focusable element inside the popup. + * @param {boolean} [options.fixedAnchor] - If 'true', the popup will fix to the value set by + * `options.anchor`. If 'false' the anchor will be dynamically set to ensure the popup falls within + * the map container with a preference for the value set by `options.anchor`. * @param {string} [options.anchor] - A string indicating the part of the popup that should * be positioned closest to the coordinate, set via {@link Popup#setLngLat}. * Options are `'center'`, `'top'`, `'bottom'`, `'left'`, `'right'`, `'top-left'`, @@ -67,7 +71,7 @@ const focusQuerySelector = [ * A pixel offset applied to the popup's location specified as: * - a single number specifying a distance from the popup's location * - a {@link PointLike} specifying a constant offset - * - an object of {@link Point}s specifing an offset for each anchor position. + * - an object of {@link Point}s specifying an offset for each anchor position. * * Negative offsets indicate left and up. * @param {string} [options.className] Space-separated CSS class names to add to popup container. @@ -540,13 +544,19 @@ export default class Popup extends Evented { } _getAnchor(bottomY: number): Anchor { - if (this.options.anchor) { return this.options.anchor; } + const fallbackPosition = 'bottom'; + if ( + this.options.fixedAnchor || + (typeof (this.options.fixedAnchor) === 'undefined' && this.options.anchor) + ) { + return this.options.anchor || fallbackPosition; + } const map = this._map; const container = this._container; const pos = this._pos; - if (!map || !container || !pos) return 'bottom'; + if (!map || !container || !pos) return fallbackPosition; const width = container.offsetWidth; const height = container.offsetHeight; @@ -568,7 +578,7 @@ export default class Popup extends Evented { if (isLeft) return 'left'; if (isRight) return 'right'; - return 'bottom'; + return this.options.anchor || fallbackPosition; } _updateClassList() { diff --git a/test/unit/ui/popup.test.js b/test/unit/ui/popup.test.js index f0c9b7ceec5..34b48f09063 100644 --- a/test/unit/ui/popup.test.js +++ b/test/unit/ui/popup.test.js @@ -399,6 +399,32 @@ test('Popup anchors as specified by the anchor option', (t) => { t.end(); }); +test('Popup anchors as specified by the anchor option when fixedAnchor is true', (t) => { + const map = createMap(t); + const popup = new Popup({anchor: 'top-left', fixedAnchor: true}) + .setLngLat([0, 0]) + .setText('Test') + .addTo(map); + map._domRenderTaskQueue.run(); + + t.ok(popup.getElement().classList.contains('mapboxgl-popup-anchor-top-left')); + t.end(); +}); + +[true, false].forEach((fixedAnchor) => { + test(`Popup anchors as bottom when anchor is undefined and fixedAnchor is ${fixedAnchor}`, (t) => { + const map = createMap(t); + const popup = new Popup({fixedAnchor}) + .setLngLat([0, 0]) + .setText('Test') + .addTo(map); + map._domRenderTaskQueue.run(); + + t.ok(popup.getElement().classList.contains('mapboxgl-popup-anchor-bottom')); + t.end(); + }); +}); + [ ['top-left', new Point(10, 10), 'translate(0,0) translate(7px,7px)'], ['top', new Point(containerWidth / 2, 10), 'translate(-50%,0) translate(0px,10px)'], @@ -413,25 +439,26 @@ test('Popup anchors as specified by the anchor option', (t) => { const anchor = args[0]; const point = args[1]; const transform = args[2]; - - test(`Popup automatically anchors to ${anchor}`, (t) => { - const map = createMap(t); - const popup = new Popup() - .setLngLat([0, 0]) - .setText('Test') - .addTo(map); - map._domRenderTaskQueue.run(); - - Object.defineProperty(popup.getElement(), 'offsetWidth', {value: 100}); - Object.defineProperty(popup.getElement(), 'offsetHeight', {value: 100}); - - t.stub(map, 'project').returns(point); - t.stub(map.transform, 'locationPoint3D').returns(point); - popup.setLngLat([0, 0]); - map._domRenderTaskQueue.run(); - - t.ok(popup.getElement().classList.contains(`mapboxgl-popup-anchor-${anchor}`)); - t.end(); + [undefined, false].forEach((fixedAnchor) => { + test(`Popup automatically anchors to ${anchor} when fixed anchor is ${fixedAnchor ?? 'undefined'}`, (t) => { + const map = createMap(t); + const popup = new Popup({fixedAnchor}) + .setLngLat([0, 0]) + .setText('Test') + .addTo(map); + map._domRenderTaskQueue.run(); + + Object.defineProperty(popup.getElement(), 'offsetWidth', {value: 100}); + Object.defineProperty(popup.getElement(), 'offsetHeight', {value: 100}); + + t.stub(map, 'project').returns(point); + t.stub(map.transform, 'locationPoint3D').returns(point); + popup.setLngLat([0, 0]); + map._domRenderTaskQueue.run(); + + t.ok(popup.getElement().classList.contains(`mapboxgl-popup-anchor-${anchor}`)); + t.end(); + }); }); test(`Popup translation reflects offset and ${anchor} anchor`, (t) => { @@ -474,6 +501,40 @@ test('Popup automatically anchors to top if its bottom offset would push it off- t.end(); }); +[ 'center', + 'top', + 'bottom', + 'left', + 'right', + 'top-left', + 'top-right', + 'bottom-left', + 'bottom-right'].forEach((anchor) => { + test(`Popup automatically anchors to value (${anchor}) position if will be on-screen`, (t) => { + const map = createMap(t); + const point = new Point(containerWidth / 2, containerHeight / 2); + const options = {offset: { + 'bottom': [0, 0], + 'top': [0, 0] + }, anchor, fixedAnchor: false}; + const popup = new Popup(options) + .setLngLat([0, 0]) + .setText('Test') + .addTo(map); + map._domRenderTaskQueue.run(); + + Object.defineProperty(popup.getElement(), 'offsetWidth', {value: containerWidth / 2}); + Object.defineProperty(popup.getElement(), 'offsetHeight', {value: containerHeight / 2}); + + t.stub(map, 'project').returns(point); + popup.setLngLat([0, 0]); + map._domRenderTaskQueue.run(); + + t.ok(popup.getElement().classList.contains(`mapboxgl-popup-anchor-${anchor}`)); + t.end(); + }); +}); + test('Popup is offset via a PointLike offset option', (t) => { const map = createMap(t); t.stub(map, 'project').returns(new Point(0, 0));