From f38380bfad5aacb76d86622e0ed85ff50f2bcf51 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 11 Sep 2026 12:51:57 +0300 Subject: [PATCH 1/7] Map: OSM provider - Add marker tooltips --- .../scss/widgets/base/_map.scss | 24 + .../ui/map/provider.dynamic.osm.engine.ts | 3 +- ...provider.dynamic.osm.openlayers.popover.ts | 5 + ...provider.dynamic.osm.openlayers.tooltip.ts | 232 +++++++ .../ui/map/provider.dynamic.osm.openlayers.ts | 93 ++- .../__internal/ui/map/provider.dynamic.osm.ts | 24 +- .../testing/helpers/forMap/openLayersMock.js | 1 + .../mapParts/osmTests.js | 600 ++++++++++++++++++ 8 files changed, 957 insertions(+), 25 deletions(-) create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.popover.ts create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts diff --git a/packages/devextreme-scss/scss/widgets/base/_map.scss b/packages/devextreme-scss/scss/widgets/base/_map.scss index 04b6e856aab6..b922caab8f47 100644 --- a/packages/devextreme-scss/scss/widgets/base/_map.scss +++ b/packages/devextreme-scss/scss/widgets/base/_map.scss @@ -37,3 +37,27 @@ .dx-map-marker-tooltip { margin: 10px; } + +.dx-map-marker-popover { + .dx-popup-content { + padding: 0; + } +} + +.dx-map-marker-popover-content { + display: flex; + overflow-wrap: anywhere; + + .dx-map-marker-tooltip { + min-width: 0; + margin-inline-end: 0; + } + + .dx-map-marker-tooltip-close { + flex-shrink: 0; + + .dx-button-content { + padding: 0; + } + } +} diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts index c252fb86c07b..71bad1ffbbc1 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.engine.ts @@ -39,6 +39,7 @@ export interface MapEngineMarkerOptions { location: MapLocation; onClick?: () => void; rtlEnabled?: boolean; + tooltip?: { text: string; visible: boolean }; } export interface MapEngineUpdateDimensionsResult { @@ -47,7 +48,7 @@ export interface MapEngineUpdateDimensionsResult { export interface MapEngineMarker { readonly originalMarker: unknown; - dispose: () => void; + dispose: (restoreFocus?: boolean) => void; } export interface MapEngineRouteOptions { diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.popover.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.popover.ts new file mode 100644 index 000000000000..146ed38addf3 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.popover.ts @@ -0,0 +1,5 @@ +import Popover from '@js/ui/popover'; + +export default class MarkerPopover extends Popover { + _updateContentSize(): void {} +} diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts new file mode 100644 index 000000000000..0c7da0c1ae7d --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts @@ -0,0 +1,232 @@ +import { normalizeKeyName } from '@js/common/core/events/utils'; +import messageLocalization from '@js/common/core/localization/message'; +import domAdapter from '@js/core/dom_adapter'; +import Guid from '@js/core/guid'; +import $ from '@js/core/renderer'; +import Button from '@js/ui/button'; +import type { Properties } from '@js/ui/popover'; +import type Popover from '@js/ui/popover'; +import type { OverlayProperties } from '@ts/ui/overlay/overlay'; +import type { PopoverProperties } from '@ts/ui/popover/popover'; + +import { DEFAULT_MARKER_CLASS } from './provider.dynamic.osm.openlayers.marker'; +import MarkerPopover from './provider.dynamic.osm.openlayers.popover'; +import type { MapLike } from './provider.dynamic.osm.openlayers.utils'; + +const TOOLTIP_CLASS = 'dx-map-marker-tooltip'; +const TOOLTIP_CLOSE_CLASS = `${TOOLTIP_CLASS}-close`; +const POPOVER_CLASS = 'dx-map-marker-popover'; +const POPOVER_CONTENT_CLASS = `${POPOVER_CLASS}-content`; +const CLOSE_BUTTON_SIZE = 28; +const TOOLTIP_MAX_WIDTH = 280; +const TOOLTIP_MAX_HEIGHT = 240; + +type MarkerPopoverOptions = Properties & Pick; + +export class OpenLayersMarkerTooltip { + readonly element: HTMLElement; + + private readonly _host: HTMLElement; + + private readonly _content: HTMLElement; + + private readonly _popover: Popover; + + private readonly _closeButton: Button; + + private readonly _closeElement: HTMLElement; + + private _triggers: HTMLElement[] = []; + + private _focusTarget?: HTMLElement; + + private _focusRequested = false; + + private _restoreFocus = false; + + constructor( + private readonly _map: MapLike, + private readonly _container: Element, + private readonly _marker: HTMLElement, + text: string, + private readonly _rtlEnabled: boolean, + ) { + const { ownerDocument } = _container; + const host = ownerDocument.createElement('div'); + _map.getOverlayContainer().appendChild(host); + this._host = host; + const content = ownerDocument.createElement('div'); + content.className = TOOLTIP_CLASS; + content.id = `dx-map-tooltip-${new Guid()}`; + content.innerHTML = text; + this._content = content; + const layout = ownerDocument.createElement('div'); + layout.className = POPOVER_CONTENT_CLASS; + const close = ownerDocument.createElement('div'); + close.className = TOOLTIP_CLOSE_CLASS; + this._closeElement = close; + this._closeButton = new Button(close, { + icon: 'close', + stylingMode: 'text', + width: CLOSE_BUTTON_SIZE, + height: CLOSE_BUTTON_SIZE, + elementAttr: { 'aria-label': messageLocalization.format('Close') }, + onClick: (): void => this._hide(), + }); + layout.append(content, close); + this._popover = new MarkerPopover(host, this._getPopoverOptions(layout)); + this.element = $(this._popover.content()).parent().get(0) as HTMLElement; + this.element.id = `${content.id}-dialog`; + this._setAccessibleName(); + this.element.addEventListener('click', this._stopPropagation); + this.element.addEventListener('dblclick', this._stopPropagation); + this.element.addEventListener('pointerdown', this._stopPropagation); + this.element.addEventListener('keydown', this._escapeKeyHandler); + this.element.addEventListener('keydown', this._stopPropagation); + _marker.addEventListener('keydown', this._escapeKeyHandler); + _map.on('postrender', this.syncPosition); + } + + private _getPopoverOptions(layout: HTMLElement): MarkerPopoverOptions { + const target = this._marker.classList.contains(DEFAULT_MARKER_CLASS) + ? this._marker.firstElementChild ?? this._marker + : this._marker; + + return { + container: this._map.getOverlayContainer(), + target, + position: { + my: { x: 'center', y: 'bottom' }, + at: { x: 'center', y: 'top' }, + collision: 'flip', + boundary: this._container, + }, + animation: undefined, + deferRendering: false, + contentTemplate: (): HTMLElement => layout, + maxWidth: TOOLTIP_MAX_WIDTH, + maxHeight: TOOLTIP_MAX_HEIGHT, + showTitle: false, + showCloseButton: false, + hideOnOutsideClick: false, + hideOnParentScroll: false, + focusStateEnabled: false, + tabFocusLoopEnabled: false, + _preventDialogContainerFocus: true, + _popoverContentRole: 'dialog', + _fixWrapperPosition: false, + enableBodyScroll: true, + rtlEnabled: this._rtlEnabled, + wrapperAttr: { class: POPOVER_CLASS }, + onShown: this._onShown, + onHiding: this._onHiding, + onHidden: this._onHidden, + }; + } + + private readonly _onShown = (): void => { + this._setExpanded(true); + this._setAccessibleName(); + if (this._focusRequested) { + this._closeElement.focus({ preventScroll: true }); + } + this._focusRequested = false; + }; + + private readonly _onHiding = (): void => { + this._restoreFocus = this.element.contains(domAdapter.getActiveElement(this.element)); + }; + + private readonly _onHidden = (): void => { + this._setExpanded(false); + if (!this._restoreFocus) { + return; + } + + if (this._focusTarget?.getAttribute('tabindex') === '-1') { + (this._container as HTMLElement).focus({ preventScroll: true }); + } else { + this._focusTarget?.focus({ preventScroll: true }); + } + }; + + private _setAccessibleName(): void { + if (this._content.textContent?.trim()) { + this.element.setAttribute('aria-labelledby', this._content.id); + } else { + this.element.setAttribute('aria-label', messageLocalization.format('dxMap-markerAriaLabel')); + } + } + + setTriggers(triggers: HTMLElement[]): void { + this._triggers = triggers; + triggers.forEach((element) => { + element.setAttribute('aria-controls', this.element.id); + element.setAttribute('aria-haspopup', 'dialog'); + element.setAttribute('aria-expanded', 'false'); + }); + } + + private _setExpanded(expanded: boolean): void { + this._triggers.forEach((element) => element.setAttribute('aria-expanded', String(expanded))); + } + + show(focus = false): void { + const activeElement = domAdapter.getActiveElement(this._marker); + this._focusTarget = this._triggers.find((element) => element === activeElement) + ?? this._triggers[0]; + this._focusRequested = focus; + const wasVisible = this._popover.option('visible'); + this._popover.option('visible', true); + if (wasVisible) { + this.syncPosition(); + if (focus) { + this._closeElement.focus({ preventScroll: true }); + this._focusRequested = false; + } + } + } + + private _hide(): void { + this._focusRequested = false; + this._popover.option('visible', false); + } + + readonly syncPosition = (): void => { + if (this._popover.option('visible')) { + this._popover.repaint(); + } + }; + + private readonly _stopPropagation = (event: Event): void => event.stopPropagation(); + + private readonly _escapeKeyHandler = (event: KeyboardEvent): void => { + if (!event.defaultPrevented + && normalizeKeyName(event) === 'escape' + && this._popover.option('visible')) { + event.preventDefault(); + event.stopPropagation(); + this._hide(); + } + }; + + dispose(): void { + this._map.un('postrender', this.syncPosition); + this.element.removeEventListener('click', this._stopPropagation); + this.element.removeEventListener('dblclick', this._stopPropagation); + this.element.removeEventListener('pointerdown', this._stopPropagation); + this.element.removeEventListener('keydown', this._escapeKeyHandler); + this.element.removeEventListener('keydown', this._stopPropagation); + this._marker.removeEventListener('keydown', this._escapeKeyHandler); + this._triggers.forEach((element) => { + element.removeAttribute('aria-controls'); + element.removeAttribute('aria-haspopup'); + element.removeAttribute('aria-expanded'); + }); + this._closeButton.dispose(); + this._popover.dispose(); + this._host.remove(); + } +} diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts index 03f58933f37c..5aabfccaa756 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -1,5 +1,6 @@ import Color from '@js/color'; import messageLocalization from '@js/common/core/localization/message'; +import domAdapter from '@js/core/dom_adapter'; import resizeObserverSingleton from '@js/core/resize_observer'; import { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/m_selectors'; @@ -25,8 +26,10 @@ import { DEFAULT_MARKER_SIZE, MARKER_FALLBACK_HEIGHT, MARKER_FALLBACK_WIDTH, + type MarkerElementInfo, type MarkerKind, } from './provider.dynamic.osm.openlayers.marker'; +import { OpenLayersMarkerTooltip } from './provider.dynamic.osm.openlayers.tooltip'; import type { ControlLike, Coordinate, @@ -77,6 +80,7 @@ interface OpenLayersMarker extends MapEngineMarker { offset: number[]; overlay: OverlayLike; positioning: string; + tooltip?: OpenLayersMarkerTooltip; } class OpenLayersMap implements MapEngineMap { @@ -192,7 +196,8 @@ class OpenLayersMap implements MapEngineMap { private _attachMarkerElementHandlers( element: HTMLElement, - onClick: MapEngineMarkerOptions['onClick'], + onClick?: (event: MouseEvent) => void, + onSizeChange?: () => void, ): MarkerElementBinding { const keyboardInteractive = Boolean(onClick) && !element.querySelector(ALL_FOCUSABLE_ELEMENTS_SELECTOR); @@ -206,9 +211,13 @@ class OpenLayersMap implements MapEngineMap { const clickHandler: EventListener | undefined = onClick ? (event): void => { event.stopPropagation(); - onClick(); + if (!this._disabled) { + onClick(event as MouseEvent); + } } : undefined; + let spacePressed = false; + const blurHandler = (): void => { spacePressed = false; }; const keydownHandler: EventListener | undefined = focusTargets.length ? (event): void => { event.stopPropagation(); @@ -223,6 +232,9 @@ class OpenLayersMap implements MapEngineMap { } event.preventDefault(); + if (keyboardEvent.key === ' ') { + spacePressed = true; + } if (keyboardEvent.key === 'Enter' && !keyboardEvent.repeat) { element.click(); } @@ -237,7 +249,10 @@ class OpenLayersMap implements MapEngineMap { event.preventDefault(); event.stopPropagation(); - element.click(); + if (spacePressed) { + spacePressed = false; + element.click(); + } } : undefined; let { height, width } = element.getBoundingClientRect(); @@ -249,6 +264,7 @@ class OpenLayersMap implements MapEngineMap { height = rect.height; width = rect.width; + onSizeChange?.(); if (this._markerSizeRefitEnabled) { this._eventHandlers?.markerSizeChange(); } @@ -273,6 +289,7 @@ class OpenLayersMap implements MapEngineMap { } if (keyReleaseHandler) { element.addEventListener(KEY_RELEASE_EVENT, keyReleaseHandler); + element.addEventListener('blur', blurHandler); } resizeObserverSingleton.observe(element, resizeHandler); @@ -287,6 +304,7 @@ class OpenLayersMap implements MapEngineMap { } if (keyReleaseHandler) { element.removeEventListener(KEY_RELEASE_EVENT, keyReleaseHandler); + element.removeEventListener('blur', blurHandler); } resizeObserverSingleton.unobserve(element); }, @@ -294,12 +312,10 @@ class OpenLayersMap implements MapEngineMap { } addMarker(options: MapEngineMarkerOptions): MapEngineMarker { + const markerElement = createMarkerElement(this._container.ownerDocument, options); const { element, kind, offset, positioning, - } = createMarkerElement( - this._container.ownerDocument, - options, - ); + } = markerElement; element.setAttribute('dir', options.rtlEnabled ? 'rtl' : 'ltr'); const marker = new this._api.Overlay({ element, @@ -310,25 +326,49 @@ class OpenLayersMap implements MapEngineMap { stopEvent: false, }); this.originalMap.addOverlay(marker); - const markerElementBinding = this._attachMarkerElementHandlers(element, options.onClick); + const tooltip = options.tooltip + ? this._createMarkerTooltip(markerElement, options.tooltip.text, Boolean(options.rtlEnabled)) + : undefined; + const onClick = options.onClick || tooltip + ? (event: MouseEvent): void => { + tooltip?.show(event.detail === 0 && this._focusEnabled); + options.onClick?.(); + } + : undefined; + const markerElementBinding = this._attachMarkerElementHandlers( + element, + onClick, + () => tooltip?.syncPosition(), + ); + tooltip?.setTriggers(markerElementBinding.focusTargets.map((target) => target.element)); + const tooltipFocusTargets = tooltip + ? Array.from(tooltip.element.querySelectorAll(ALL_FOCUSABLE_ELEMENTS_SELECTOR)) + .map((target) => ({ element: target, tabIndex: target.getAttribute('tabindex') })) + : []; this._markerSizeRefitEnabled = true; let disposed = false; const handle: OpenLayersMarker = { element, - focusTargets: markerElementBinding.focusTargets, + focusTargets: [...markerElementBinding.focusTargets, ...tooltipFocusTargets], kind, location: { ...options.location }, offset, overlay: marker, positioning, + tooltip, originalMarker: marker, - dispose: (): void => { + dispose: (restoreFocus = true): void => { if (disposed) { return; } disposed = true; + if (restoreFocus && !this._disposed + && tooltip?.element.contains(domAdapter.getActiveElement(tooltip.element))) { + (this._container as HTMLElement).focus({ preventScroll: true }); + } + tooltip?.dispose(); markerElementBinding.detach(); this.originalMap.removeOverlay(marker); this._markers.delete(handle); @@ -337,10 +377,27 @@ class OpenLayersMap implements MapEngineMap { this._markers.add(handle); this._syncMarkerTabIndex(handle); + if (options.tooltip?.visible) { + tooltip?.show(); + } return handle; } + private _createMarkerTooltip( + element: MarkerElementInfo, + text: string, + rtlEnabled: boolean, + ): OpenLayersMarkerTooltip { + return new OpenLayersMarkerTooltip( + this.originalMap, + this._container, + element.element, + text, + rtlEnabled, + ); + } + addRoute(options: MapEngineRouteOptions): MapEngineRoute { const { _api: api } = this; const geometry = new api.geom.LineString(toRouteCoordinates(options.locations)); @@ -396,16 +453,18 @@ class OpenLayersMap implements MapEngineMap { const position = this._getMarkerPosition(marker.location); if (!areCoordinatesEqual(marker.overlay.getPosition(), position)) { marker.overlay.setPosition(position); + marker.tooltip?.syncPosition(); } }); } private _syncMarkerTabIndex(marker: OpenLayersMarker, viewExtent?: Extent): void { const extent = viewExtent ?? this.originalMap.getView().calculateExtent(); - const isVisible = this._isMarkerVisible(marker, extent); + const isVisible = this._isMarkerVisible(marker.overlay, extent); marker.focusTargets.forEach(({ element, tabIndex }) => { - if (!this._focusEnabled || this._disabled || !isVisible) { + const isMarkerOutsideView = !isVisible && marker.element.contains(element); + if (!this._focusEnabled || this._disabled || isMarkerOutsideView) { element.setAttribute('tabindex', '-1'); } else if (tabIndex === null) { element.removeAttribute('tabindex'); @@ -425,8 +484,8 @@ class OpenLayersMap implements MapEngineMap { this._markers.forEach((marker) => this._syncMarkerTabIndex(marker, viewExtent)); } - private _isMarkerVisible(marker: OpenLayersMarker, viewExtent: Extent): boolean { - const position = marker.overlay.getPosition(); + private _isMarkerVisible(marker: OverlayLike, viewExtent: Extent): boolean { + const position = marker.getPosition(); if (!position) { return false; } @@ -443,7 +502,7 @@ class OpenLayersMap implements MapEngineMap { private _moveMarkerFocusToMap(marker: OpenLayersMarker): void { const markerRoot = marker.element.getRootNode() as Document | ShadowRoot; const { activeElement } = markerRoot; - const markerHasFocus = marker.focusTargets.some(({ element }) => element === activeElement); + const markerHasFocus = marker.element.contains(activeElement); const container = this._container as HTMLElement; if (markerHasFocus && typeof container.focus === 'function') { @@ -538,7 +597,9 @@ class OpenLayersMap implements MapEngineMap { return false; } - return [...this._markers].some(({ element }) => element.contains(eventTarget)); + return [...this._markers].some(({ element, tooltip }) => ( + element.contains(eventTarget) || tooltip?.element.contains(eventTarget) + )); } private _detachHandlers(): void { diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts index d199090815bb..dc846b340b6c 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -144,6 +144,8 @@ const areLocationsEqual = ( class OsmProvider extends DynamicProvider { declare _routes: (EngineRouteObject & { options: RouteOptions })[]; + private _isCleaning = false; + _engine?: MapEngine; _engineMap?: MapEngineMap; @@ -450,6 +452,7 @@ class OsmProvider extends DynamicProvider { ? (): void => markerClickAction({ location }) : undefined, rtlEnabled: Boolean(this._option('rtlEnabled')), + tooltip: options.tooltip ? this._parseTooltipOptions(options.tooltip) : undefined, }); return { @@ -461,7 +464,7 @@ class OsmProvider extends DynamicProvider { } _destroyMarker(marker: EngineMarkerObject): void { - marker.engineMarker.dispose(); + marker.engineMarker.dispose(!this._isCleaning); } _fitBounds(): Promise { @@ -602,14 +605,19 @@ class OsmProvider extends DynamicProvider { } clean(): Promise { - if (this._engineMap) { - this._clearMarkers(); - this._clearRoutes(); + this._isCleaning = true; + try { + if (this._engineMap) { + this._clearMarkers(); + this._clearRoutes(); + } + this._engineMap?.dispose(); + this._engineMap = undefined; + this._engine = undefined; + this._map = undefined; + } finally { + this._isCleaning = false; } - this._engineMap?.dispose(); - this._engineMap = undefined; - this._engine = undefined; - this._map = undefined; return Promise.resolve(); } diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js index e65588ec4165..3902000642ce 100644 --- a/packages/devextreme/testing/helpers/forMap/openLayersMock.js +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -138,6 +138,7 @@ this.eventHandlers = {}; this.overlayContainer = document.createElement('div'); this.overlayContainerStopEvent = document.createElement('div'); + options.target.append(this.overlayContainer, this.overlayContainerStopEvent); api.mapCreated = true; api.mapInstance = this; api.mapOptions = options; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 82e47bde0ebb..81fa5c0d5cfe 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -3,8 +3,11 @@ import $ from 'jquery'; import OsmProvider from '__internal/ui/map/provider.dynamic.osm'; import { setRegisteredMapEngine } from '__internal/ui/map/provider.dynamic.osm.engine'; import { createOpenLayersEngine } from '__internal/ui/map/provider.dynamic.osm.openlayers'; +import MarkerPopover from '__internal/ui/map/provider.dynamic.osm.openlayers.popover'; +import coreErrors from 'core/errors'; import resizeObserverSingleton from 'core/resize_observer'; import localization from 'localization'; +import SelectBox from 'ui/select_box'; import errors from 'ui/widget/ui.errors'; import 'ui/map'; @@ -1443,6 +1446,8 @@ QUnit.module('OSM: markers', moduleConfig, () => { const element = openLayersMock.addedOverlays[0].options.element; assert.strictEqual(element.getAttribute('role'), 'button', 'wrapper has button semantics'); assert.strictEqual(element.getAttribute('tabindex'), '0', 'wrapper is keyboard-focusable'); + element.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true })); + assert.ok(onClick.notCalled, 'Space release without a preceding keydown does not activate the marker'); element.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, @@ -1454,6 +1459,10 @@ QUnit.module('OSM: markers', moduleConfig, () => { bubbles: true })); assert.ok(onClick.calledOnce, 'HTML marker can be activated from the keyboard'); + element.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + element.dispatchEvent(new FocusEvent('blur')); + element.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true })); + assert.ok(onClick.calledOnce, 'losing focus cancels the pending Space activation'); done(); } }); @@ -1904,6 +1913,597 @@ QUnit.module('OSM: markers', moduleConfig, () => { }); }); }); +QUnit.module('OSM: marker tooltips', moduleConfig, () => { + const location = { lat: 40.74, lng: -73.98 }; + const createMap = (options = {}) => new Promise(resolve => { + $('#map').dxMap({ + provider: 'osm', + autoAdjust: false, + providerConfig: { + tileServer: { url: 'https://tiles.example.com/{z}/{x}/{y}.png', attribution: 'Example' } + }, + ...options, + onReady: ({ component }) => resolve(component) + }); + }); + const getPopovers = () => Array.from(document.querySelectorAll('#map .dx-popover')) + .map(element => MarkerPopover.getInstance(element)); + const getTooltip = () => getPopovers()[0]; + const getContent = popover => $(popover.content())[0]; + const getMarker = () => openLayersMock.addedOverlays[0]; + + QUnit.test('tooltip creation does not use deprecated options', async function(assert) { + const log = sinon.stub(coreErrors, 'log'); + try { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + assert.ok(log.withArgs('W0001').notCalled, 'no deprecated option warning is logged'); + assert.strictEqual(getTooltip().option('preventScrollEvents'), false, 'popover allows scrolling without an explicit deprecated option'); + } finally { + log.restore(); + } + }); + + QUnit.test('string tooltip opens on marker click without a callback', async function(assert) { + const onClick = sinon.spy(); + await createMap({ markers: [{ location, tooltip: 'Start' }], onClick }); + const marker = getMarker(); + const tooltip = getTooltip(); + const centerSetCount = openLayersMock.viewCenterSetCount; + assert.notOk(tooltip.option('visible'), 'string tooltip is initially hidden'); + assert.strictEqual(marker.options.element.getAttribute('tabindex'), '0', 'tooltip makes the marker interactive'); + marker.options.element.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })); + assert.ok(tooltip.option('visible'), 'activation opens the tooltip'); + assert.strictEqual(tooltip.option('target'), marker.options.element.querySelector('svg'), 'tooltip targets the visible icon, not the larger hit area'); + assert.strictEqual(tooltip.option('position').offset, undefined, 'no extra offset separates the arrow from the icon'); + assert.strictEqual(openLayersMock.viewCenterSetCount, centerSetCount, 'showing the tooltip does not pan the map'); + assert.ok(onClick.notCalled, 'marker activation is not a map click'); + assert.strictEqual(marker.options.element.getAttribute('aria-expanded'), 'true', 'expanded state is exposed'); + }); + + [undefined, ''].forEach(tooltip => { + QUnit.test(`no tooltip widget for ${String(tooltip)}`, async function(assert) { + await createMap({ markers: [{ location, tooltip }] }); + assert.strictEqual(openLayersMock.addedOverlays.length, 1, 'only the marker is created'); + assert.strictEqual(getPopovers().length, 0, 'no tooltip is created'); + }); + }); + + QUnit.test('empty tooltip object has an accessible fallback name', async function(assert) { + await createMap({ markers: [{ location, tooltip: { isShown: true } }] }); + assert.strictEqual(getContent(getTooltip()).parentElement.getAttribute('aria-label'), localization.formatMessage('dxMap-markerAriaLabel'), 'empty content does not leave the dialog unnamed'); + }); + + ['Enter', ' '].forEach(key => { + QUnit.test(`keyboard activation and Escape restore marker focus (${key})`, async function(assert) { + const onClick = sinon.spy(); + await createMap({ markers: [{ location, tooltip: 'Start', onClick }] }); + const markerElement = getMarker().options.element; + const tooltip = getTooltip(); + markerElement.focus(); + markerElement.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + markerElement.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true, cancelable: true })); + const closeButton = getContent(tooltip).parentElement.querySelector('.dx-map-marker-tooltip-close'); + assert.strictEqual(document.activeElement, closeButton, 'keyboard activation focuses the close action'); + assert.strictEqual(closeButton.getAttribute('aria-label'), localization.formatMessage('Close'), 'close action is localized'); + assert.strictEqual(onClick.callCount, 1, 'marker callback fires once'); + assert.deepEqual(onClick.firstCall.args[0].location, location, 'callback receives the existing location payload'); + closeButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + assert.notOk(tooltip.option('visible'), 'Escape hides the popup'); + assert.strictEqual(document.activeElement, markerElement, 'focus returns without scrolling'); + assert.strictEqual(markerElement.getAttribute('aria-expanded'), 'false', 'expanded state is reset'); + }); + }); + + QUnit.test('Escape closes a nested SelectBox before its marker tooltip', async function(assert) { + await createMap({ markers: [{ location, tooltip: { text: '
', isShown: true } }] }); + const tooltip = getTooltip(); + const selectBox = new SelectBox(getContent(tooltip).querySelector('.nested-select-box'), { + items: ['First', 'Second'], + value: 'First', + dropDownOptions: { animation: undefined } + }); + try { + selectBox.focus(); + selectBox.open(); + const input = getContent(tooltip).querySelector('.dx-texteditor-input'); + assert.ok(selectBox.option('opened'), 'the nested list is open'); + assert.strictEqual(document.activeElement, input, 'the nested editor has focus'); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + + assert.notOk(selectBox.option('opened'), 'the first Escape closes the nested list'); + assert.ok(tooltip.option('visible'), 'the tooltip stays open when the editor handles Escape'); + assert.strictEqual(document.activeElement, input, 'focus stays in the nested editor'); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + + assert.notOk(tooltip.option('visible'), 'the next Escape closes the tooltip'); + assert.strictEqual(document.activeElement, getMarker().options.element, 'focus returns to the marker'); + } finally { + selectBox.dispose(); + } + }); + + QUnit.test('tooltip content can stop Escape propagation', async function(assert) { + await createMap({ markers: [{ location, tooltip: { text: '', isShown: true } }] }); + const tooltip = getTooltip(); + const input = getContent(tooltip).querySelector('input'); + input.focus(); + input.addEventListener('keydown', event => event.stopPropagation(), { once: true }); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + + assert.ok(tooltip.option('visible'), 'the tooltip stays open when its content stops Escape'); + assert.strictEqual(document.activeElement, input, 'focus stays in the tooltip content'); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + + assert.notOk(tooltip.option('visible'), 'unhandled Escape closes the tooltip'); + assert.strictEqual(document.activeElement, getMarker().options.element, 'focus returns to the marker'); + }); + + ['marker', 'tooltip content'].forEach(focusTarget => { + QUnit.test(`Escape closes only the focused marker's tooltip (focus: ${focusTarget})`, async function(assert) { + await createMap({ + markers: [ + { location, tooltip: { text: 'First', isShown: true } }, + { location, tooltip: { text: 'Second', isShown: true } } + ] + }); + const [firstTooltip, secondTooltip] = getPopovers(); + const firstMarker = getMarker().options.element; + const target = focusTarget === 'marker' + ? firstMarker + : getContent(firstTooltip).querySelector('.dx-map-marker-tooltip-close'); + assert.ok(firstTooltip.option('visible'), 'first tooltip is open'); + assert.ok(secondTooltip.option('visible'), 'second tooltip is open'); + target.focus(); + assert.strictEqual(document.activeElement, target, 'focus is in the first marker or its tooltip'); + + target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + + assert.notOk(firstTooltip.option('visible'), 'Escape hides the tooltip associated with focus'); + assert.ok(secondTooltip.option('visible'), 'the tooltip opened last remains visible'); + assert.strictEqual(document.activeElement, firstMarker, 'focus stays on or returns to the first marker'); + }); + }); + + QUnit.test('Escape on a marker with a closed tooltip leaves other tooltips open', async function(assert) { + await createMap({ + markers: [ + { location, tooltip: { text: 'First', isShown: true } }, + { location, tooltip: { text: 'Second', isShown: true } }, + { location, tooltip: 'Third' } + ] + }); + const [firstTooltip, secondTooltip, thirdTooltip] = getPopovers(); + const marker = openLayersMock.addedOverlays[2].options.element; + marker.focus(); + assert.strictEqual(document.activeElement, marker, 'the marker with the closed tooltip has focus'); + + marker.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + + assert.ok(firstTooltip.option('visible'), 'first tooltip stays open'); + assert.ok(secondTooltip.option('visible'), 'the last opened tooltip stays open'); + assert.notOk(thirdTooltip.option('visible'), 'the focused marker tooltip stays closed'); + assert.strictEqual(document.activeElement, marker, 'focus stays on the marker'); + }); + + ['Enter', ' '].forEach(key => { + QUnit.test(`closing a tooltip with ${key === ' ' ? 'Space' : key} preserves marker size refitting`, async function(assert) { + let size = 0; + openLayersMock.getOverlayRect = () => ({ height: size, width: size }); + const map = await createMap({ + autoAdjust: true, + markers: [{ location, iconSrc: 'marker.png', tooltip: { text: 'Start', isShown: true } }] + }); + const tooltip = getTooltip(); + const close = getContent(tooltip).querySelector('.dx-map-marker-tooltip-close'); + close.focus(); + close.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + assert.notOk(tooltip.option('visible'), 'the close button still handles keyboard activation'); + const fitCount = openLayersMock.fitCallCount; + assert.ok(fitCount > 0, 'autoAdjust has fitted the markers'); + + size = 60; + triggerResize(getMarker().options.element); + await map._lastAsyncAction; + + assert.strictEqual(openLayersMock.fitCallCount, fitCount + 1, 'image layout still refits the map after tooltip interaction'); + }); + }); + + QUnit.test('removing a marker with focus in its tooltip focuses the map without scrolling', async function(assert) { + const marker = { location, tooltip: { text: 'Start', isShown: true } }; + const map = await createMap({ markers: [marker] }); + const close = getContent(getTooltip()).querySelector('.dx-map-marker-tooltip-close'); + close.focus(); + assert.strictEqual(document.activeElement, close, 'focus starts in the tooltip'); + const target = getOpenLayersMapTarget(); + const focus = sinon.spy(target, 'focus'); + + await map.removeMarker(marker); + + assert.strictEqual(document.activeElement, target, 'focus moves to the remaining map'); + assert.ok(focus.calledOnceWithExactly({ preventScroll: true }), 'restoring focus does not scroll the container'); + assert.strictEqual(getPopovers().length, 0, 'the tooltip is removed'); + }); + + QUnit.test('removing a marker does not steal focus from outside its tooltip', async function(assert) { + const marker = { location, tooltip: { text: 'Start', isShown: true } }; + const map = await createMap({ markers: [marker] }); + const input = $('').appendTo('#qunit-fixture')[0]; + input.focus(); + const focus = sinon.spy(getOpenLayersMapTarget(), 'focus'); + + await map.removeMarker(marker); + + assert.strictEqual(document.activeElement, input, 'external focus is preserved'); + assert.ok(focus.notCalled, 'the map is not focused'); + }); + + QUnit.test('disposing the map does not focus its disappearing container', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: { text: 'Start', isShown: true } }] }); + const close = getContent(getTooltip()).querySelector('.dx-map-marker-tooltip-close'); + close.focus(); + assert.strictEqual(document.activeElement, close, 'focus starts in the tooltip'); + const focus = sinon.spy(getOpenLayersMapTarget(), 'focus'); + + map.dispose(); + + assert.ok(focus.notCalled, 'cleanup does not restore focus to the map'); + assert.strictEqual(getPopovers().length, 0, 'the tooltip is disposed'); + }); + + QUnit.test('disabled markers cannot open tooltips', async function(assert) { + const onClick = sinon.spy(); + const map = await createMap({ markers: [{ location, tooltip: 'Start', onClick }] }); + map.option('disabled', true); + await map._lastAsyncAction; + const marker = getMarker().options.element; + marker.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })); + + assert.notOk(getTooltip().option('visible'), 'the tooltip stays hidden while the map is disabled'); + assert.ok(onClick.notCalled, 'the marker callback is not invoked'); + + map.option('disabled', false); + await map._lastAsyncAction; + marker.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })); + + assert.ok(getTooltip().option('visible'), 'the marker can open its tooltip after enabling the map'); + assert.ok(onClick.calledOnce, 'the enabled marker invokes its callback'); + }); + + ['Enter', ' '].forEach(key => { + QUnit.test(`focusStateEnabled false prevents tooltip focus during synthetic ${key === ' ' ? 'Space' : key} activation`, async function(assert) { + await createMap({ focusStateEnabled: false, markers: [{ location, tooltip: 'Start' }] }); + const marker = getMarker().options.element; + const input = $('').appendTo('#qunit-fixture')[0]; + input.focus(); + marker.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + marker.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true, cancelable: true })); + + const close = getContent(getTooltip()).querySelector('.dx-map-marker-tooltip-close'); + assert.ok(getTooltip().option('visible'), 'synthetic activation can open the tooltip'); + assert.strictEqual(document.activeElement, input, 'activation does not move focus into the tooltip'); + assert.strictEqual(marker.tabIndex, -1, 'the marker is outside the tab order'); + assert.strictEqual(close.tabIndex, -1, 'the close action is outside the tab order'); + }); + }); + + [false, true].forEach(rtlEnabled => { + QUnit.test(`tooltip keyboard, ARIA and removal work inside Shadow DOM (rtlEnabled: ${rtlEnabled})`, function(assert) { + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const container = document.createElement('div'); + shadowRoot.appendChild(container); + $('#qunit-fixture').append(host); + const engineMap = createOpenLayersEngine(openLayersMock).createMap(container); + try { + const marker = engineMap.addMarker({ location, rtlEnabled, tooltip: { text: 'Start', visible: true } }); + const markerElement = marker.originalMarker.options.element; + const tooltip = MarkerPopover.getInstance(shadowRoot.querySelector('.dx-popover')); + const popup = getContent(tooltip).parentElement; + const close = popup.querySelector('.dx-map-marker-tooltip-close'); + assert.strictEqual(popup.getRootNode(), shadowRoot, 'popup remains in the marker Shadow Root'); + assert.strictEqual(shadowRoot.getElementById(markerElement.getAttribute('aria-controls')), popup, 'aria-controls resolves in the same tree'); + assert.strictEqual(shadowRoot.getElementById(popup.getAttribute('aria-labelledby')).textContent, 'Start', 'the accessible name resolves in the same tree'); + assert.strictEqual(tooltip.option('rtlEnabled'), rtlEnabled, 'popup receives the map direction'); + + markerElement.focus(); + markerElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true, cancelable: true })); + assert.strictEqual(shadowRoot.activeElement, close, 'keyboard activation focuses Close'); + close.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true, cancelable: true })); + assert.notOk(tooltip.option('visible'), 'Escape closes the shadow popup'); + assert.strictEqual(shadowRoot.activeElement, markerElement, 'Escape restores marker focus'); + + markerElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true, cancelable: true })); + assert.strictEqual(shadowRoot.activeElement, close, 'focus returns to the reopened tooltip'); + marker.dispose(); + assert.strictEqual(shadowRoot.activeElement, container, 'removal restores focus to the shadow map target'); + assert.notOk(shadowRoot.querySelector('.dx-map-marker-popover'), 'removal leaves no popup behind'); + } finally { + engineMap.dispose(); + $(host).remove(); + } + }); + }); + + QUnit.test('popup clicks do not reach the map but wheel events do', async function(assert) { + const onClick = sinon.spy(); + await createMap({ markers: [{ location, tooltip: { text: 'More', isShown: true } }], onClick }); + const tooltipElement = getContent(getTooltip()).parentElement; + const mapElement = getOpenLayersMapTarget(); + const click = sinon.spy(); + const wheel = sinon.spy(); + mapElement.addEventListener('click', click); + mapElement.addEventListener('wheel', wheel); + tooltipElement.querySelector('a').dispatchEvent(new MouseEvent('click', { bubbles: true })); + tooltipElement.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); + openLayersMock.mapInstance.trigger('click', { + coordinate: [-73980, 40740], originalEvent: { target: tooltipElement } + }); + assert.ok(click.notCalled, 'DOM click does not bubble to the map'); + assert.ok(onClick.notCalled, 'OpenLayers click is filtered for popup content'); + assert.ok(wheel.calledOnce, 'wheel can reach map interactions'); + $(tooltipElement.querySelector('.dx-map-marker-tooltip-close')).trigger('dxclick'); + assert.notOk(getTooltip().option('visible'), 'close button hides the tooltip'); + mapElement.removeEventListener('click', click); + mapElement.removeEventListener('wheel', wheel); + }); + + [ + { iconSrc: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=' }, + { html: 'Stop', htmlOffset: { left: 4, top: 10 } }, + { html: '' } + ].forEach(options => { + QUnit.test(`tooltip supports a custom marker (${options.iconSrc ? 'image' : options.html})`, async function(assert) { + openLayersMock.getOverlayRect = () => ({ height: 60, width: 40 }); + await createMap({ markers: [{ location, ...options, tooltip: { text: 'Stop', isShown: true } }], rtlEnabled: true }); + const tooltip = getTooltip(); + assert.strictEqual(tooltip.option('target'), getMarker().options.element, 'popover targets the custom marker'); + assert.ok(tooltip.option('rtlEnabled'), 'popup inherits RTL'); + assert.ok(tooltip.option('visible'), 'tooltip is visible'); + }); + }); + + QUnit.test('tooltip position follows resized images and wrapped map views', async function(assert) { + let height = 40; + openLayersMock.getOverlayRect = () => ({ height, width: 25 }); + await createMap({ markers: [{ location, iconSrc: 'marker.png', tooltip: { text: 'Start', isShown: true } }] }); + const tooltip = getTooltip(); + const repaint = sinon.spy(tooltip, 'repaint'); + height = 70; + triggerResize(getMarker().options.element); + assert.ok(repaint.called, 'loaded image size updates popover positioning'); + repaint.resetHistory(); + openLayersMock.viewExtent = [285900, 40600, 286200, 40900]; + openLayersMock.mapInstance.getView().setCenter([286020, 40740]); + assert.ok(repaint.called, 'the wrapped view updates popover positioning'); + assert.strictEqual(tooltip.option('target'), getMarker().options.element, 'popover stays attached to the same marker'); + }); + + QUnit.test('popup keyboard access respects map options independently of marker visibility', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: { text: 'More', isShown: true } }] }); + const popup = getContent(getTooltip()).parentElement; + const close = popup.querySelector('.dx-map-marker-tooltip-close'); + const link = popup.querySelector('a'); + map.option('disabled', true); + await map._lastAsyncAction; + assert.strictEqual(close.tabIndex, -1, 'disabled close action is outside the tab order'); + assert.strictEqual(link.tabIndex, -1, 'disabled content is outside the tab order'); + assert.ok(getOpenLayersMapTarget().hasAttribute('inert'), 'map inert covers popup content'); + map.option({ disabled: false, focusStateEnabled: false }); + await map._lastAsyncAction; + assert.strictEqual(close.tabIndex, -1, 'focusStateEnabled false applies to the popup'); + map.option('focusStateEnabled', true); + await map._lastAsyncAction; + assert.strictEqual(close.tabIndex, 0, 'close button focusability is restored'); + close.focus(); + openLayersMock.viewExtent = [0, 0, 100, 100]; + openLayersMock.mapInstance.trigger('moveend'); + assert.strictEqual(getMarker().options.element.tabIndex, -1, 'offscreen marker is outside the tab order'); + assert.strictEqual(close.tabIndex, 0, 'the visible popup remains in the tab order'); + assert.strictEqual(document.activeElement, close, 'the visible popup retains focus'); + close.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + assert.notOk(getTooltip().option('visible'), 'Escape closes the popup'); + assert.strictEqual(document.activeElement, getOpenLayersMapTarget(), 'focus returns to the map when its marker is offscreen'); + }); + + QUnit.test('runtime tooltip updates, removal and disposal clean up widgets and handlers', async function(assert) { + const marker = { location, tooltip: 'Old text' }; + const map = await createMap({ markers: [marker] }); + const oldMarker = getMarker(); + const oldHost = $('#map .dx-popover')[0]; + const oldDispose = sinon.spy(getTooltip(), 'dispose'); + map.option('markers[0].tooltip', { text: 'New text', isShown: true }); + await map._lastAsyncAction; + assert.ok(oldDispose.calledOnce, 'old popup is disposed on update'); + assert.notOk(oldHost.isConnected, 'old widget host is removed'); + const currentTooltip = getTooltip(); + oldMarker.options.element.click(); + assert.strictEqual(getPopovers().length, 1, 'detached marker cannot recreate the old popup'); + assert.strictEqual(getContent(currentTooltip).querySelector('.dx-map-marker-tooltip').textContent, 'New text', 'new tooltip content is rendered'); + assert.ok(currentTooltip.option('visible'), 'updated isShown is applied'); + const currentDispose = sinon.spy(currentTooltip, 'dispose'); + await map.removeMarker(map.option('markers')[0]); + assert.ok(currentDispose.calledOnce, 'removeMarker disposes the popup'); + assert.strictEqual(getPopovers().length, 0, 'no popup host remains'); + await map.addMarker({ location, tooltip: 'Added later' }); + const latestDispose = sinon.spy(getTooltip(), 'dispose'); + map.dispose(); + assert.ok(latestDispose.calledOnce, 'map disposal disposes the popup'); + assert.strictEqual(document.querySelectorAll('.dx-map-marker-popover').length, 0, 'no popup wrapper remains'); + assert.strictEqual(resizeObserverCallbacks.size, 0, 'no marker resize observer is retained'); + }); + QUnit.test('existing and newly added markers use dxPopover', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'First' }, { location, tooltip: 'Second' }] }); + assert.strictEqual(getPopovers().length, 2, 'both tooltips use dxPopover'); + assert.strictEqual(openLayersMock.addedOverlays.length, 2, 'OpenLayers overlays are used only for markers'); + await map.addMarker({ location, tooltip: 'Third' }); + assert.strictEqual(getPopovers().length, 3, 'addMarker also creates a popover'); + openLayersMock.addedOverlays[0].options.element.click(); + assert.ok(getPopovers()[0].option('visible'), 'marker activation opens the popover'); + }); + + [false, true].forEach(rtlEnabled => { + QUnit.test(`initial visibility preserves HTML without taking focus (rtlEnabled: ${rtlEnabled})`, async function(assert) { + const activeElement = document.activeElement; + await createMap({ markers: [{ location, tooltip: { text: 'Start', isShown: true } }], rtlEnabled }); + const popover = getPopovers()[0]; + assert.ok(popover.option('visible'), 'isShown is applied'); + assert.strictEqual(popover.option('rtlEnabled'), rtlEnabled, 'map direction reaches dxPopover'); + assert.strictEqual(getMarker().options.element.getAttribute('dir'), rtlEnabled ? 'rtl' : 'ltr', 'marker content keeps its direction'); + assert.strictEqual(getContent(popover).querySelector('b').textContent, 'Start', 'HTML is rendered'); + assert.strictEqual(document.activeElement, activeElement, 'initial popup does not steal focus'); + assert.strictEqual(getContent(popover).parentElement.getAttribute('role'), 'dialog', 'popup has dialog semantics'); + }); + }); + + QUnit.test('compact content preserves keyboard activation, Escape and the close action', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + const marker = openLayersMock.addedOverlays[0].options.element; + const popover = getPopovers()[0]; + marker.focus(); + marker.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + const content = getContent(popover); + const close = content.querySelector('.dx-map-marker-tooltip-close'); + assert.notOk(popover.option('showTitle'), 'there is no empty title area'); + assert.ok(close.classList.contains('dx-button'), 'Close uses dxButton'); + assert.ok(close.querySelector('.dx-icon-close'), 'Close uses the existing icon'); + assert.strictEqual(close.getAttribute('aria-label'), localization.formatMessage('Close'), 'Close has an accessible name'); + assert.strictEqual(document.activeElement, close, 'keyboard activation focuses Close'); + close.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + assert.notOk(popover.option('visible'), 'Escape closes the popover'); + assert.strictEqual(document.activeElement, marker, 'focus returns to the marker'); + openLayersMock.mapInstance.trigger('postrender'); + assert.notOk(popover.option('visible'), 'Escape remains closed after rendering'); + marker.click(); + $(close).trigger('dxclick'); + assert.notOk(popover.option('visible'), 'close button works'); + }); + + QUnit.test('close dxButton supports Enter and Space', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + const marker = openLayersMock.addedOverlays[0].options.element; + const popover = getPopovers()[0]; + const close = getContent(popover).querySelector('.dx-map-marker-tooltip-close'); + ['Enter', ' '].forEach(key => { + marker.click(); + close.focus(); + close.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + assert.notOk(popover.option('visible'), `${key === ' ' ? 'Space' : key} activates Close`); + assert.strictEqual(document.activeElement, marker, 'focus returns to the marker'); + document.activeElement.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true, cancelable: true })); + openLayersMock.mapInstance.trigger('postrender'); + assert.notOk(popover.option('visible'), 'releasing the key after focus returns does not reopen the popover'); + }); + }); + + QUnit.test('popup events, disabled and focusStateEnabled remain scoped to the map', async function(assert) { + const onClick = sinon.spy(); + const map = await createMap({ markers: [{ location, tooltip: { text: 'More', isShown: true } }], onClick }); + const element = getContent(getPopovers()[0]).parentElement; + const link = element.querySelector('a'); + const wheel = sinon.spy(); + const target = getOpenLayersMapTarget(); + target.addEventListener('wheel', wheel); + link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + element.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); + openLayersMock.mapInstance.trigger('click', { coordinate: [-73980, 40740], originalEvent: { target: element } }); + assert.ok(onClick.notCalled, 'popover click is not a map click'); + assert.ok(wheel.calledOnce, 'wheel reaches the map'); + map.option('disabled', true); + await map._lastAsyncAction; + assert.ok(target.hasAttribute('inert'), 'map inert includes dxPopover'); + assert.strictEqual(link.tabIndex, -1, 'disabled links are outside tab order'); + map.option({ disabled: false, focusStateEnabled: false }); + await map._lastAsyncAction; + assert.strictEqual(link.tabIndex, -1, 'focusStateEnabled also covers dxPopover'); + target.removeEventListener('wheel', wheel); + }); + + QUnit.test('render synchronization is removed when the marker is removed', async function(assert) { + const marker = { location, tooltip: { text: 'Start', isShown: true } }; + const map = await createMap({ markers: [marker] }); + const popover = getPopovers()[0]; + const repaint = sinon.spy(popover, 'repaint'); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(repaint.calledOnce, 'native popover positioning runs after map rendering'); + await map.removeMarker(marker); + repaint.resetHistory(); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(repaint.notCalled, 'removed popup no longer receives map renders'); + assert.strictEqual(getPopovers().length, 0, 'widget host is removed'); + assert.strictEqual(document.querySelectorAll('.dx-map-marker-popover').length, 0, 'popup wrapper is removed'); + }); + + QUnit.test('an open popover stays visible beyond marker viewport boundaries without stealing focus', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + const marker = openLayersMock.addedOverlays[0].options.element; + const popover = getPopovers()[0]; + marker.focus(); + marker.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + assert.ok(popover.option('visible'), 'keyboard activation opens the popover'); + const input = $('').appendTo('#qunit-fixture')[0]; + input.focus(); + const extent = openLayersMock.viewExtent; + openLayersMock.viewExtent = [0, 0, 1, 1]; + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(popover.option('visible'), 'popover stays visible when its marker leaves the viewport'); + assert.strictEqual(marker.getAttribute('aria-expanded'), 'true', 'the visible popup remains expanded'); + assert.strictEqual(document.activeElement, input, 'leaving the viewport does not move focus'); + openLayersMock.viewExtent = extent; + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(popover.option('visible'), 'popover remains visible when the marker returns'); + assert.strictEqual(marker.getAttribute('aria-expanded'), 'true', 'visible popup is reported as expanded'); + assert.strictEqual(document.activeElement, input, 'returning popup does not repeat the keyboard focus request'); + $(getContent(popover).querySelector('.dx-map-marker-tooltip-close')).trigger('dxclick'); + openLayersMock.viewExtent = [0, 0, 1, 1]; + openLayersMock.mapInstance.trigger('postrender'); + openLayersMock.viewExtent = extent; + openLayersMock.mapInstance.trigger('postrender'); + assert.notOk(popover.option('visible'), 'Close is preserved after the marker leaves and returns'); + }); + + QUnit.test('popover flips near the map edge and leaves it without resizing or fitting', async function(assert) { + await createMap({ width: 600, height: 400, markers: [{ location, tooltip: 'Start' }] }); + const marker = getMarker().options.element; + const popover = getTooltip(); + const popup = getContent(popover).parentElement; + const boundary = getOpenLayersMapTarget().getBoundingClientRect(); + $(marker).css({ position: 'absolute', left: 300, top: 200 }); + marker.click(); + const initial = popup.getBoundingClientRect(); + assert.ok(initial.height > 0, 'tooltip has content'); + $(marker).css('top', 0); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(popup.getBoundingClientRect().top >= marker.getBoundingClientRect().bottom, 'tooltip flips below the marker near the top'); + $(marker).css('top', -90); + openLayersMock.mapInstance.trigger('postrender'); + const outside = popup.getBoundingClientRect(); + assert.ok(popover.option('visible'), 'leaving the boundary does not close the tooltip'); + assert.ok(outside.top < boundary.top, 'tooltip follows its marker outside the map instead of fitting inside'); + assert.strictEqual(outside.height, initial.height, 'crossing the boundary does not shrink the tooltip'); + assert.strictEqual(outside.width, initial.width, 'crossing the boundary does not change the width'); + }); + + QUnit.test('initially shown popover is visible even when its marker is offscreen', async function(assert) { + const extent = openLayersMock.viewExtent; + openLayersMock.viewExtent = [0, 0, 1, 1]; + const activeElement = document.activeElement; + await createMap({ markers: [{ location, tooltip: { text: 'Start', isShown: true } }] }); + const popover = getPopovers()[0]; + assert.ok(popover.option('visible'), 'isShown displays the popup independently of marker visibility'); + assert.strictEqual(document.activeElement, activeElement, 'initial showing does not take focus'); + openLayersMock.viewExtent = extent; + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(popover.option('visible'), 'popover stays visible when the marker enters the viewport'); + assert.strictEqual(document.activeElement, activeElement, 'automatic showing does not take focus'); + }); + +}); + QUnit.module('OSM: routes', moduleConfig, () => { const tileServer = { url: 'https://tiles.example.com/{z}/{x}/{y}.png', From 6ca93f560ba2f1d4ff80c61557b7f89a3df84722 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 11 Sep 2026 12:52:31 +0300 Subject: [PATCH 2/7] Map: OSM provider - Showcase marker tooltips in Storybook --- .../stories/map/OSMMap.stories.tsx | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index a6ca9ad15817..ec22b995e14e 100644 --- a/apps/react-storybook/stories/map/OSMMap.stories.tsx +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -116,6 +116,8 @@ interface OsmStoryArgs { controls: boolean; disabled: boolean; focusStateEnabled: boolean; + tooltipsEnabled: boolean; + tooltipsInitiallyShown: boolean; rtlEnabled: boolean; showRoute: boolean; routeColor: string; @@ -157,6 +159,8 @@ const OsmMapStory = ({ controls, disabled, focusStateEnabled, + tooltipsEnabled, + tooltipsInitiallyShown, rtlEnabled, showRoute, routeColor, @@ -170,14 +174,20 @@ const OsmMapStory = ({ const mapRef = React.useRef(null); const [markerAdded, setMarkerAdded] = React.useState(false); const preset = ROUTE_PRESETS[routePreset]; - const markers = React.useMemo(() => preset.markers.map((marker) => ({ + const markers = React.useMemo(() => preset.markers.map((marker, index) => ({ ...marker, onClick: handleMarkerClick, - })), [preset]); + tooltip: tooltipsEnabled + ? index === 0 ? 'Start' : { + text: `Stop ${index + 1}
Explore this location.`, + isShown: tooltipsInitiallyShown, + } + : undefined, + })), [preset, tooltipsEnabled, tooltipsInitiallyShown]); const addedMarker = React.useMemo(() => ({ location: preset.extraMarker, - onClick: handleMarkerClick, - }), [preset]); + tooltip: tooltipsEnabled ? 'Additional stop' : undefined, + }), [preset, tooltipsEnabled]); const routes = React.useMemo(() => showRoute ? [{ locations: preset.locations, color: routeColor, @@ -189,6 +199,9 @@ const OsmMapStory = ({ React.useEffect(() => { setMarkerAdded(false); + }, [preset, tooltipsEnabled, tooltipsInitiallyShown]); + + React.useEffect(() => { mapRef.current?.instance()?.option('zoom', preset.zoom); }, [preset]); @@ -286,6 +299,11 @@ const meta: Meta = { focusStateEnabled: { control: 'boolean', }, + tooltipsEnabled: { control: 'boolean' }, + tooltipsInitiallyShown: { + control: 'boolean', + description: 'Sets isShown for object-form tooltips. The Start marker uses a string tooltip.', + }, rtlEnabled: { control: 'boolean', }, @@ -336,6 +354,8 @@ export const Default: Story = { controls: true, disabled: false, focusStateEnabled: true, + tooltipsEnabled: true, + tooltipsInitiallyShown: false, rtlEnabled: false, showRoute: true, routeColor: '#0000ff', From e4e9d1a1928578bf89e5f9528c4a2b8425530c7a Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 11 Sep 2026 12:57:36 +0300 Subject: [PATCH 3/7] Map: OSM provider - Rename Storybook story to Overview --- apps/react-storybook/stories/map/OSMMap.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index ec22b995e14e..566feb0a4cb1 100644 --- a/apps/react-storybook/stories/map/OSMMap.stories.tsx +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -347,7 +347,7 @@ export default meta; type Story = StoryObj; -export const Default: Story = { +export const Overview: Story = { args: { autoAdjust: false, centerOnCentralPark: false, From bd2a5e068321861f5f5fc02c51d3af8c5868be49 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 11 Sep 2026 13:28:47 +0300 Subject: [PATCH 4/7] Map: Update ThemeBuilder dependencies for marker tooltips --- packages/devextreme-themebuilder/tests/data/dependencies.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devextreme-themebuilder/tests/data/dependencies.ts b/packages/devextreme-themebuilder/tests/data/dependencies.ts index 8e7ffd2d604a..1705cd611ddf 100644 --- a/packages/devextreme-themebuilder/tests/data/dependencies.ts +++ b/packages/devextreme-themebuilder/tests/data/dependencies.ts @@ -40,7 +40,7 @@ export const dependencies: FlatStylesDependencies = { htmleditor: ['validation', 'button', 'loadindicator', 'loadpanel', 'scrollview', 'popup', 'toolbar', 'textbox', 'list', 'checkbox', 'selectbox', 'numberbox', 'multiview', 'tabs', 'tabpanel', 'box', 'responsivebox', 'calendar', 'datebox', 'form', 'buttongroup', 'colorbox', 'progressbar', 'fileuploader', 'contextmenu', 'textarea', 'menu', 'dropdownbutton', 'treeview', 'informer'], sortable: [], lookup: ['validation', 'button', 'loadindicator', 'textbox', 'popup', 'loadpanel', 'scrollview', 'list', 'popover'], - map: [], + map: ['button', 'loadindicator', 'loadpanel', 'popover', 'popup', 'scrollview', 'toolbar', 'validation'], radiogroup: ['validation'], tooltip: ['validation', 'button', 'popup', 'popover'], slider: ['validation', 'button', 'popup', 'popover', 'tooltip'], From 17a93d08394fc6ab7f01c336cebd118b200adede Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 18 Sep 2026 10:24:18 +0400 Subject: [PATCH 5/7] Map: OSM provider - Use standard Popover for marker tooltips --- .../scss/widgets/base/_map.scss | 24 - ...provider.dynamic.osm.openlayers.popover.ts | 5 - ...provider.dynamic.osm.openlayers.tooltip.ts | 286 +++--- .../ui/map/provider.dynamic.osm.openlayers.ts | 16 +- .../mapParts/osmTests.js | 815 +++++++----------- 5 files changed, 467 insertions(+), 679 deletions(-) delete mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.popover.ts diff --git a/packages/devextreme-scss/scss/widgets/base/_map.scss b/packages/devextreme-scss/scss/widgets/base/_map.scss index b922caab8f47..04b6e856aab6 100644 --- a/packages/devextreme-scss/scss/widgets/base/_map.scss +++ b/packages/devextreme-scss/scss/widgets/base/_map.scss @@ -37,27 +37,3 @@ .dx-map-marker-tooltip { margin: 10px; } - -.dx-map-marker-popover { - .dx-popup-content { - padding: 0; - } -} - -.dx-map-marker-popover-content { - display: flex; - overflow-wrap: anywhere; - - .dx-map-marker-tooltip { - min-width: 0; - margin-inline-end: 0; - } - - .dx-map-marker-tooltip-close { - flex-shrink: 0; - - .dx-button-content { - padding: 0; - } - } -} diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.popover.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.popover.ts deleted file mode 100644 index 146ed38addf3..000000000000 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.popover.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Popover from '@js/ui/popover'; - -export default class MarkerPopover extends Popover { - _updateContentSize(): void {} -} diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts index 0c7da0c1ae7d..dd4ca1926fe5 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts @@ -1,232 +1,220 @@ import { normalizeKeyName } from '@js/common/core/events/utils'; -import messageLocalization from '@js/common/core/localization/message'; import domAdapter from '@js/core/dom_adapter'; -import Guid from '@js/core/guid'; import $ from '@js/core/renderer'; -import Button from '@js/ui/button'; import type { Properties } from '@js/ui/popover'; -import type Popover from '@js/ui/popover'; -import type { OverlayProperties } from '@ts/ui/overlay/overlay'; -import type { PopoverProperties } from '@ts/ui/popover/popover'; +import Popover from '@js/ui/popover'; +import { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/m_selectors'; +import type InternalPopover from '@ts/ui/popover/popover'; import { DEFAULT_MARKER_CLASS } from './provider.dynamic.osm.openlayers.marker'; -import MarkerPopover from './provider.dynamic.osm.openlayers.popover'; import type { MapLike } from './provider.dynamic.osm.openlayers.utils'; -const TOOLTIP_CLASS = 'dx-map-marker-tooltip'; -const TOOLTIP_CLOSE_CLASS = `${TOOLTIP_CLASS}-close`; const POPOVER_CLASS = 'dx-map-marker-popover'; -const POPOVER_CONTENT_CLASS = `${POPOVER_CLASS}-content`; -const CLOSE_BUTTON_SIZE = 28; const TOOLTIP_MAX_WIDTH = 280; -const TOOLTIP_MAX_HEIGHT = 240; -type MarkerPopoverOptions = Properties & Pick; +type MarkerPopover = Popover & Pick; export class OpenLayersMarkerTooltip { readonly element: HTMLElement; private readonly _host: HTMLElement; - private readonly _content: HTMLElement; + private readonly _popover: MarkerPopover; - private readonly _popover: Popover; + private readonly _inertElements = new Set(); - private readonly _closeButton: Button; + private readonly _tabIndexes = new Map(); - private readonly _closeElement: HTMLElement; + private _focusEnabled = true; - private _triggers: HTMLElement[] = []; + private _positioning = false; - private _focusTarget?: HTMLElement; + private _positionUpdatePending = false; - private _focusRequested = false; - - private _restoreFocus = false; + private _disposed = false; constructor( private readonly _map: MapLike, private readonly _container: Element, private readonly _marker: HTMLElement, text: string, - private readonly _rtlEnabled: boolean, + rtlEnabled: boolean, ) { const { ownerDocument } = _container; const host = ownerDocument.createElement('div'); + Object.assign(host.style, { position: 'absolute', inset: '0', contain: 'layout paint' }); _map.getOverlayContainer().appendChild(host); this._host = host; + const element = ownerDocument.createElement('div'); + host.appendChild(element); const content = ownerDocument.createElement('div'); - content.className = TOOLTIP_CLASS; - content.id = `dx-map-tooltip-${new Guid()}`; content.innerHTML = text; - this._content = content; - const layout = ownerDocument.createElement('div'); - layout.className = POPOVER_CONTENT_CLASS; - const close = ownerDocument.createElement('div'); - close.className = TOOLTIP_CLOSE_CLASS; - this._closeElement = close; - this._closeButton = new Button(close, { - icon: 'close', - stylingMode: 'text', - width: CLOSE_BUTTON_SIZE, - height: CLOSE_BUTTON_SIZE, - elementAttr: { 'aria-label': messageLocalization.format('Close') }, - onClick: (): void => this._hide(), - }); - layout.append(content, close); - this._popover = new MarkerPopover(host, this._getPopoverOptions(layout)); - this.element = $(this._popover.content()).parent().get(0) as HTMLElement; - this.element.id = `${content.id}-dialog`; - this._setAccessibleName(); - this.element.addEventListener('click', this._stopPropagation); - this.element.addEventListener('dblclick', this._stopPropagation); - this.element.addEventListener('pointerdown', this._stopPropagation); - this.element.addEventListener('keydown', this._escapeKeyHandler); - this.element.addEventListener('keydown', this._stopPropagation); - _marker.addEventListener('keydown', this._escapeKeyHandler); - _map.on('postrender', this.syncPosition); - } - - private _getPopoverOptions(layout: HTMLElement): MarkerPopoverOptions { - const target = this._marker.classList.contains(DEFAULT_MARKER_CLASS) - ? this._marker.firstElementChild ?? this._marker - : this._marker; - - return { - container: this._map.getOverlayContainer(), - target, + const target = _marker.classList.contains(DEFAULT_MARKER_CLASS) + ? _marker.firstElementChild ?? _marker + : _marker; + const focusTargets = _marker.querySelectorAll(ALL_FOCUSABLE_ELEMENTS_SELECTOR); + + this._popover = new Popover(element, { + container: host, + // @ts-expect-error Popover also supports renderer collections as targets. + target: focusTargets.length ? $(Array.from(focusTargets)) : _marker, position: { + of: target, my: { x: 'center', y: 'bottom' }, at: { x: 'center', y: 'top' }, collision: 'flip', - boundary: this._container, + boundary: _container, }, animation: undefined, deferRendering: false, - contentTemplate: (): HTMLElement => layout, + contentTemplate: (): HTMLElement => content, maxWidth: TOOLTIP_MAX_WIDTH, - maxHeight: TOOLTIP_MAX_HEIGHT, showTitle: false, showCloseButton: false, hideOnOutsideClick: false, hideOnParentScroll: false, - focusStateEnabled: false, - tabFocusLoopEnabled: false, - _preventDialogContainerFocus: true, - _popoverContentRole: 'dialog', - _fixWrapperPosition: false, - enableBodyScroll: true, - rtlEnabled: this._rtlEnabled, + rtlEnabled, + elementAttr: { class: POPOVER_CLASS }, wrapperAttr: { class: POPOVER_CLASS }, - onShown: this._onShown, - onHiding: this._onHiding, - onHidden: this._onHidden, - }; + }) as MarkerPopover; + this.element = $(this._popover.content()).parent().get(0) as HTMLElement; + this._popover.on('showing', this._prepareShowing); + this._popover.on('positioned', this._restoreContentSize); + this._popover.on('positioned', this._syncFocusState); + this._popover.on('shown', this._syncFocusState); + this._popover.on('hidden', this._syncFocusState); + this.element.addEventListener('click', this._stopPropagation); + this.element.addEventListener('dblclick', this._stopPropagation); + this.element.addEventListener('pointerdown', this._stopPropagation); + this.element.addEventListener('keydown', this._stopMapKeyPropagation); + _map.on('postrender', this.syncPosition); } - private readonly _onShown = (): void => { - this._setExpanded(true); - this._setAccessibleName(); - if (this._focusRequested) { - this._closeElement.focus({ preventScroll: true }); + show(): void { + if (!this._disposed) { + this._popover.option('visible', true); } - this._focusRequested = false; - }; - - private readonly _onHiding = (): void => { - this._restoreFocus = this.element.contains(domAdapter.getActiveElement(this.element)); - }; + } - private readonly _onHidden = (): void => { - this._setExpanded(false); - if (!this._restoreFocus) { - return; + setFocusEnabled(enabled: boolean): void { + if (enabled !== this._focusEnabled) { + this._focusEnabled = enabled; + const focusEnabled = enabled && this.element.getAttribute('role') === 'dialog'; + this._popover.option({ + focusStateEnabled: focusEnabled, + tabFocusLoopEnabled: focusEnabled, + }); } + this._syncFocusState(); + } - if (this._focusTarget?.getAttribute('tabindex') === '-1') { - (this._container as HTMLElement).focus({ preventScroll: true }); - } else { - this._focusTarget?.focus({ preventScroll: true }); + private readonly _prepareShowing = (): void => { + if (!this._focusEnabled) { + this._popover.option({ focusStateEnabled: false, tabFocusLoopEnabled: false }); } + this._syncFocusState(); }; - private _setAccessibleName(): void { - if (this._content.textContent?.trim()) { - this.element.setAttribute('aria-labelledby', this._content.id); - } else { - this.element.setAttribute('aria-label', messageLocalization.format('dxMap-markerAriaLabel')); + private readonly _restoreContentSize = (): Promise | undefined => { + if (this._positioning || this._positionUpdatePending) { + return undefined; } - } - setTriggers(triggers: HTMLElement[]): void { - this._triggers = triggers; - triggers.forEach((element) => { - element.setAttribute('aria-controls', this.element.id); - element.setAttribute('aria-haspopup', 'dialog'); - element.setAttribute('aria-expanded', 'false'); - }); - } - - private _setExpanded(expanded: boolean): void { - this._triggers.forEach((element) => element.setAttribute('aria-expanded', String(expanded))); - } - - show(focus = false): void { - const activeElement = domAdapter.getActiveElement(this._marker); - this._focusTarget = this._triggers.find((element) => element === activeElement) - ?? this._triggers[0]; - this._focusRequested = focus; - const wasVisible = this._popover.option('visible'); - this._popover.option('visible', true); - if (wasVisible) { - this.syncPosition(); - if (focus) { - this._closeElement.focus({ preventScroll: true }); - this._focusRequested = false; + this._positionUpdatePending = true; + return Promise.resolve().then(() => { + this._positionUpdatePending = false; + if (this._disposed) { + return; } - } - } - private _hide(): void { - this._focusRequested = false; - this._popover.option('visible', false); - } + this._popover._renderDimensions(); + this._popover._setContentHeight(true); + this.syncPosition(); + }); + }; readonly syncPosition = (): void => { if (this._popover.option('visible')) { - this._popover.repaint(); + this._positioning = true; + try { + this._popover._renderPosition(false); + } finally { + this._positioning = false; + } } + this._syncFocusState(); }; private readonly _stopPropagation = (event: Event): void => event.stopPropagation(); - private readonly _escapeKeyHandler = (event: KeyboardEvent): void => { - if (!event.defaultPrevented - && normalizeKeyName(event) === 'escape' - && this._popover.option('visible')) { - event.preventDefault(); + private readonly _stopMapKeyPropagation = (event: KeyboardEvent): void => { + const key = normalizeKeyName(event); + if (event.defaultPrevented || (key !== 'escape' && key !== 'tab')) { event.stopPropagation(); - this._hide(); } }; + private readonly _syncFocusState = (): void => { + this._inertElements.forEach((element) => { element.inert = false; }); + this._inertElements.clear(); + if (this._focusEnabled) { + this._tabIndexes.forEach((tabIndex, element) => { + if (tabIndex === null) { + element.removeAttribute('tabindex'); + } else { + element.setAttribute('tabindex', tabIndex); + } + }); + this._tabIndexes.clear(); + } + const focusTargets = this.element + .querySelectorAll(ALL_FOCUSABLE_ELEMENTS_SELECTOR); + if (!this._focusEnabled) { + focusTargets.forEach((element) => { + if (!this._tabIndexes.has(element)) { + this._tabIndexes.set(element, element.getAttribute('tabindex')); + } + element.setAttribute('tabindex', '-1'); + }); + } + const boundary = this._container.getBoundingClientRect(); + const activeElement = domAdapter.getActiveElement(this.element); + const elements = this._popover.option('visible') && this.element.getClientRects().length + ? [this._marker, this.element, ...focusTargets] + : [this._marker]; + + elements.forEach((element) => { + const rect = element.getBoundingClientRect(); + const outside = element === this.element + ? rect.bottom <= boundary.top || rect.top >= boundary.bottom + || rect.right <= boundary.left || rect.left >= boundary.right + : rect.top < boundary.top || rect.bottom > boundary.bottom + || rect.left < boundary.left || rect.right > boundary.right; + + if (outside) { + if (element.contains(activeElement)) { + (this._container as HTMLElement).focus({ preventScroll: true }); + } + if (!element.inert) { + element.inert = true; + this._inertElements.add(element); + } + } + }); + }; + dispose(): void { + this._disposed = true; this._map.un('postrender', this.syncPosition); this.element.removeEventListener('click', this._stopPropagation); this.element.removeEventListener('dblclick', this._stopPropagation); this.element.removeEventListener('pointerdown', this._stopPropagation); - this.element.removeEventListener('keydown', this._escapeKeyHandler); - this.element.removeEventListener('keydown', this._stopPropagation); - this._marker.removeEventListener('keydown', this._escapeKeyHandler); - this._triggers.forEach((element) => { - element.removeAttribute('aria-controls'); - element.removeAttribute('aria-haspopup'); - element.removeAttribute('aria-expanded'); - }); - this._closeButton.dispose(); + this.element.removeEventListener('keydown', this._stopMapKeyPropagation); this._popover.dispose(); this._host.remove(); + this._inertElements.forEach((element) => { element.inert = false; }); + this._inertElements.clear(); + this._tabIndexes.clear(); } } diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts index 5aabfccaa756..71f757c66ae9 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.ts @@ -220,7 +220,9 @@ class OpenLayersMap implements MapEngineMap { const blurHandler = (): void => { spacePressed = false; }; const keydownHandler: EventListener | undefined = focusTargets.length ? (event): void => { - event.stopPropagation(); + if ((event as KeyboardEvent).key !== 'Escape' || event.defaultPrevented) { + event.stopPropagation(); + } if (!keyboardInteractive) { return; @@ -330,9 +332,9 @@ class OpenLayersMap implements MapEngineMap { ? this._createMarkerTooltip(markerElement, options.tooltip.text, Boolean(options.rtlEnabled)) : undefined; const onClick = options.onClick || tooltip - ? (event: MouseEvent): void => { - tooltip?.show(event.detail === 0 && this._focusEnabled); + ? (): void => { options.onClick?.(); + tooltip?.show(); } : undefined; const markerElementBinding = this._attachMarkerElementHandlers( @@ -340,17 +342,12 @@ class OpenLayersMap implements MapEngineMap { onClick, () => tooltip?.syncPosition(), ); - tooltip?.setTriggers(markerElementBinding.focusTargets.map((target) => target.element)); - const tooltipFocusTargets = tooltip - ? Array.from(tooltip.element.querySelectorAll(ALL_FOCUSABLE_ELEMENTS_SELECTOR)) - .map((target) => ({ element: target, tabIndex: target.getAttribute('tabindex') })) - : []; this._markerSizeRefitEnabled = true; let disposed = false; const handle: OpenLayersMarker = { element, - focusTargets: [...markerElementBinding.focusTargets, ...tooltipFocusTargets], + focusTargets: markerElementBinding.focusTargets, kind, location: { ...options.location }, offset, @@ -459,6 +456,7 @@ class OpenLayersMap implements MapEngineMap { } private _syncMarkerTabIndex(marker: OpenLayersMarker, viewExtent?: Extent): void { + marker.tooltip?.setFocusEnabled(this._focusEnabled && !this._disabled); const extent = viewExtent ?? this.originalMap.getView().calculateExtent(); const isVisible = this._isMarkerVisible(marker.overlay, extent); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 81fa5c0d5cfe..abec01880def 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -3,10 +3,10 @@ import $ from 'jquery'; import OsmProvider from '__internal/ui/map/provider.dynamic.osm'; import { setRegisteredMapEngine } from '__internal/ui/map/provider.dynamic.osm.engine'; import { createOpenLayersEngine } from '__internal/ui/map/provider.dynamic.osm.openlayers'; -import MarkerPopover from '__internal/ui/map/provider.dynamic.osm.openlayers.popover'; import coreErrors from 'core/errors'; import resizeObserverSingleton from 'core/resize_observer'; import localization from 'localization'; +import Popover from 'ui/popover'; import SelectBox from 'ui/select_box'; import errors from 'ui/widget/ui.errors'; @@ -1919,6 +1919,8 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { $('#map').dxMap({ provider: 'osm', autoAdjust: false, + width: 600, + height: 400, providerConfig: { tileServer: { url: 'https://tiles.example.com/{z}/{x}/{y}.png', attribution: 'Example' } }, @@ -1926,76 +1928,167 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { onReady: ({ component }) => resolve(component) }); }); - const getPopovers = () => Array.from(document.querySelectorAll('#map .dx-popover')) - .map(element => MarkerPopover.getInstance(element)); + const getPopovers = (root = document) => Array.from(root.querySelectorAll('.dx-map-marker-popover.dx-popover')) + .map(element => Popover.getInstance(element)); const getTooltip = () => getPopovers()[0]; const getContent = popover => $(popover.content())[0]; - const getMarker = () => openLayersMock.addedOverlays[0]; + const getMarker = () => openLayersMock.addedOverlays[0].options.element; + const positionMarker = (marker = getMarker(), top = 200) => $(marker).css({ position: 'absolute', left: 300, top }); + + QUnit.test('tooltips use the standard Popover without a title, Close button or custom styles', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'First' }] }); + await map.addMarker({ location, tooltip: 'Second' }); + assert.strictEqual(getPopovers().length, 2, 'initial and added markers have popovers'); + getPopovers().forEach(popover => { + assert.strictEqual(popover.constructor, Popover, 'no subclass'); + assert.notOk(popover.option('showTitle'), 'no title'); + assert.notOk(popover.option('showCloseButton'), 'no Close button'); + assert.notOk(getContent(popover).querySelector('.dx-button'), 'no custom Close button'); + assert.strictEqual(popover.option('wrapperAttr').class, 'dx-map-marker-popover', 'customization hook'); + }); + }); QUnit.test('tooltip creation does not use deprecated options', async function(assert) { const log = sinon.stub(coreErrors, 'log'); try { await createMap({ markers: [{ location, tooltip: 'Start' }] }); - assert.ok(log.withArgs('W0001').notCalled, 'no deprecated option warning is logged'); - assert.strictEqual(getTooltip().option('preventScrollEvents'), false, 'popover allows scrolling without an explicit deprecated option'); + assert.ok(log.withArgs('W0001').notCalled, 'no deprecated option warning'); } finally { log.restore(); } }); - QUnit.test('string tooltip opens on marker click without a callback', async function(assert) { + QUnit.test('disposing the map in the marker callback does not show its removed popover', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'Start', onClick: () => map.dispose() }] }); + const show = sinon.spy(getTooltip(), 'show'); + getMarker().click(); + assert.notOk(show.called, 'disposed popover is not shown'); + assert.strictEqual(getPopovers().length, 0, 'popover is removed'); + }); + + QUnit.test('a string tooltip opens without a marker callback and describes its marker', async function(assert) { const onClick = sinon.spy(); await createMap({ markers: [{ location, tooltip: 'Start' }], onClick }); + positionMarker(); const marker = getMarker(); const tooltip = getTooltip(); - const centerSetCount = openLayersMock.viewCenterSetCount; - assert.notOk(tooltip.option('visible'), 'string tooltip is initially hidden'); - assert.strictEqual(marker.options.element.getAttribute('tabindex'), '0', 'tooltip makes the marker interactive'); - marker.options.element.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })); - assert.ok(tooltip.option('visible'), 'activation opens the tooltip'); - assert.strictEqual(tooltip.option('target'), marker.options.element.querySelector('svg'), 'tooltip targets the visible icon, not the larger hit area'); - assert.strictEqual(tooltip.option('position').offset, undefined, 'no extra offset separates the arrow from the icon'); - assert.strictEqual(openLayersMock.viewCenterSetCount, centerSetCount, 'showing the tooltip does not pan the map'); - assert.ok(onClick.notCalled, 'marker activation is not a map click'); - assert.strictEqual(marker.options.element.getAttribute('aria-expanded'), 'true', 'expanded state is exposed'); + assert.notOk(tooltip.option('visible'), 'initially hidden'); + marker.click(); + const popup = getContent(tooltip).parentElement; + assert.ok(tooltip.option('visible'), 'click opens the tooltip'); + assert.strictEqual(popup.getAttribute('role'), 'tooltip', 'uses native semantics'); + assert.strictEqual(marker.getAttribute('aria-describedby'), popup.id, 'native description'); + assert.strictEqual(tooltip.option('target'), marker, 'focusable marker is the target'); + assert.strictEqual(tooltip.option('position').of, marker.firstElementChild, 'arrow targets the visible icon'); + assert.ok(onClick.notCalled, 'marker click is not a map click'); + }); + + [false, true].forEach(rtlEnabled => { + QUnit.test(`initial visibility preserves HTML and focus (RTL: ${rtlEnabled})`, async function(assert) { + const activeElement = document.activeElement; + await createMap({ rtlEnabled, markers: [{ location, tooltip: { text: 'Start', isShown: true } }] }); + const tooltip = getTooltip(); + assert.ok(tooltip.option('visible'), 'isShown is respected'); + assert.strictEqual(tooltip.option('rtlEnabled'), rtlEnabled, 'direction is inherited'); + assert.strictEqual(getContent(tooltip).querySelector('b').textContent, 'Start', 'HTML is preserved'); + assert.strictEqual(document.activeElement, activeElement, 'no focus is stolen'); + }); }); - [undefined, ''].forEach(tooltip => { - QUnit.test(`no tooltip widget for ${String(tooltip)}`, async function(assert) { - await createMap({ markers: [{ location, tooltip }] }); - assert.strictEqual(openLayersMock.addedOverlays.length, 1, 'only the marker is created'); - assert.strictEqual(getPopovers().length, 0, 'no tooltip is created'); + ['Enter', ' '].forEach(key => { + QUnit.test(`keyboard activation (${key}) keeps focus on the marker and supports Escape`, async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + const marker = getMarker(); + marker.focus(); + marker.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + marker.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true, cancelable: true })); + assert.ok(getTooltip().option('visible'), 'keyboard opens the tooltip'); + assert.strictEqual(document.activeElement, marker, 'focus remains on the marker'); + marker.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + assert.notOk(getTooltip().option('visible'), 'native Popover handles Escape'); }); }); - QUnit.test('empty tooltip object has an accessible fallback name', async function(assert) { - await createMap({ markers: [{ location, tooltip: { isShown: true } }] }); - assert.strictEqual(getContent(getTooltip()).parentElement.getAttribute('aria-label'), localization.formatMessage('dxMap-markerAriaLabel'), 'empty content does not leave the dialog unnamed'); + QUnit.test('marker callback receives coordinates and can customize the popover before its first showing', async function(assert) { + const onClick = sinon.spy(({ location: coordinates }) => { + assert.deepEqual(coordinates, location, 'resolved coordinates are passed'); + assert.notOk(getTooltip().option('visible'), 'callback runs before showing'); + getTooltip().option({ showTitle: true, title: 'Details', showCloseButton: true }); + }); + await createMap({ markers: [{ location, tooltip: 'Start', onClick }] }); + positionMarker(); + getMarker().click(); + assert.ok(onClick.calledOnce, 'one callback'); + assert.ok(getTooltip().option('visible'), 'popover is shown'); + assert.ok(getContent(getTooltip()).parentElement.querySelector('.dx-closebutton'), 'public options add Close'); }); - ['Enter', ' '].forEach(key => { - QUnit.test(`keyboard activation and Escape restore marker focus (${key})`, async function(assert) { - const onClick = sinon.spy(); - await createMap({ markers: [{ location, tooltip: 'Start', onClick }] }); - const markerElement = getMarker().options.element; + [false, true].forEach(focusStateEnabled => { + QUnit.test(`dialog focus is consistent on first and repeated showing (focusStateEnabled: ${focusStateEnabled})`, async function(assert) { + await createMap({ + focusStateEnabled, + markers: [{ + location, + tooltip: 'Start', + onClick: () => getTooltip().option({ title: 'Details', showTitle: true, showCloseButton: true }) + }] + }); + positionMarker(); + const input = $('').appendTo('#qunit-fixture')[0]; const tooltip = getTooltip(); - markerElement.focus(); - markerElement.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); - markerElement.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true, cancelable: true })); - const closeButton = getContent(tooltip).parentElement.querySelector('.dx-map-marker-tooltip-close'); - assert.strictEqual(document.activeElement, closeButton, 'keyboard activation focuses the close action'); - assert.strictEqual(closeButton.getAttribute('aria-label'), localization.formatMessage('Close'), 'close action is localized'); - assert.strictEqual(onClick.callCount, 1, 'marker callback fires once'); - assert.deepEqual(onClick.firstCall.args[0].location, location, 'callback receives the existing location payload'); - closeButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); - assert.notOk(tooltip.option('visible'), 'Escape hides the popup'); - assert.strictEqual(document.activeElement, markerElement, 'focus returns without scrolling'); - assert.strictEqual(markerElement.getAttribute('aria-expanded'), 'false', 'expanded state is reset'); + for(let attempt = 0; attempt < 2; attempt++) { + input.focus(); + getMarker().click(); + const popup = getContent(tooltip).parentElement; + const close = popup.querySelector('.dx-closebutton'); + assert.notOk(popup.inert, 'the visible popup is not inert'); + assert.strictEqual(document.activeElement, focusStateEnabled ? close : input, 'native autofocus respects the map'); + assert.strictEqual(tooltip.option('tabFocusLoopEnabled'), focusStateEnabled, 'the focus loop respects the map'); + await tooltip.hide(); + } }); }); + QUnit.test('dialog keyboard access is restored after changing focusStateEnabled', async function(assert) { + const map = await createMap({ focusStateEnabled: false, markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + const tooltip = getTooltip(); + tooltip.option({ title: 'Details', showTitle: true, showCloseButton: true }); + getMarker().click(); + const close = getContent(tooltip).parentElement.querySelector('.dx-closebutton'); + assert.strictEqual(close.tabIndex, -1, 'Close is excluded from tab navigation'); + map.option('focusStateEnabled', true); + await map._lastAsyncAction; + assert.strictEqual(close.tabIndex, 0, 'Close is restored to tab navigation'); + assert.ok(tooltip.option('focusStateEnabled'), 'native focus is enabled'); + assert.ok(tooltip.option('tabFocusLoopEnabled'), 'native focus loop is enabled'); + await tooltip.hide(); + getMarker().click(); + assert.strictEqual(document.activeElement, close, 'native autofocus works after enabling'); + }); + + QUnit.test('tooltip describes the focusable children of an HTML marker', async function(assert) { + await createMap({ markers: [{ + location, + html: '', + tooltip: 'Details' + }] }); + positionMarker(); + const marker = getMarker(); + const buttons = marker.querySelectorAll('button'); + buttons[0].click(); + const popup = getContent(getTooltip()).parentElement; + assert.strictEqual(buttons[0].getAttribute('aria-describedby'), `existing-description ${popup.id}`, 'first button keeps its existing description'); + assert.strictEqual(buttons[1].getAttribute('aria-describedby'), popup.id, 'second button is also described'); + assert.notOk(marker.hasAttribute('aria-describedby'), 'the non-focusable wrapper is not described'); + assert.strictEqual(getTooltip().option('position').of, marker, 'position remains relative to the entire marker'); + }); + QUnit.test('Escape closes a nested SelectBox before its marker tooltip', async function(assert) { - await createMap({ markers: [{ location, tooltip: { text: '
', isShown: true } }] }); + await createMap({ markers: [{ location, tooltip: '
' }] }); + positionMarker(); + getMarker().click(); const tooltip = getTooltip(); const selectBox = new SelectBox(getContent(tooltip).querySelector('.nested-select-box'), { items: ['First', 'Second'], @@ -2007,501 +2100,239 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { selectBox.open(); const input = getContent(tooltip).querySelector('.dx-texteditor-input'); assert.ok(selectBox.option('opened'), 'the nested list is open'); - assert.strictEqual(document.activeElement, input, 'the nested editor has focus'); - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); - assert.notOk(selectBox.option('opened'), 'the first Escape closes the nested list'); - assert.ok(tooltip.option('visible'), 'the tooltip stays open when the editor handles Escape'); - assert.strictEqual(document.activeElement, input, 'focus stays in the nested editor'); - + assert.ok(tooltip.option('visible'), 'the tooltip remains open'); + assert.strictEqual(document.activeElement, input, 'the editor retains focus'); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); - assert.notOk(tooltip.option('visible'), 'the next Escape closes the tooltip'); - assert.strictEqual(document.activeElement, getMarker().options.element, 'focus returns to the marker'); } finally { selectBox.dispose(); } }); - QUnit.test('tooltip content can stop Escape propagation', async function(assert) { - await createMap({ markers: [{ location, tooltip: { text: '', isShown: true } }] }); - const tooltip = getTooltip(); - const input = getContent(tooltip).querySelector('input'); - input.focus(); - input.addEventListener('keydown', event => event.stopPropagation(), { once: true }); - - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); - - assert.ok(tooltip.option('visible'), 'the tooltip stays open when its content stops Escape'); - assert.strictEqual(document.activeElement, input, 'focus stays in the tooltip content'); - - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); - - assert.notOk(tooltip.option('visible'), 'unhandled Escape closes the tooltip'); - assert.strictEqual(document.activeElement, getMarker().options.element, 'focus returns to the marker'); - }); - - ['marker', 'tooltip content'].forEach(focusTarget => { - QUnit.test(`Escape closes only the focused marker's tooltip (focus: ${focusTarget})`, async function(assert) { - await createMap({ - markers: [ - { location, tooltip: { text: 'First', isShown: true } }, - { location, tooltip: { text: 'Second', isShown: true } } - ] - }); - const [firstTooltip, secondTooltip] = getPopovers(); - const firstMarker = getMarker().options.element; - const target = focusTarget === 'marker' - ? firstMarker - : getContent(firstTooltip).querySelector('.dx-map-marker-tooltip-close'); - assert.ok(firstTooltip.option('visible'), 'first tooltip is open'); - assert.ok(secondTooltip.option('visible'), 'second tooltip is open'); - target.focus(); - assert.strictEqual(document.activeElement, target, 'focus is in the first marker or its tooltip'); - - target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); - - assert.notOk(firstTooltip.option('visible'), 'Escape hides the tooltip associated with focus'); - assert.ok(secondTooltip.option('visible'), 'the tooltip opened last remains visible'); - assert.strictEqual(document.activeElement, firstMarker, 'focus stays on or returns to the first marker'); - }); + QUnit.test('onShowing can cancel showing without changing the marker click location', async function(assert) { + let clickLocation; + await createMap({ markers: [{ + location, + tooltip: 'Start', + onClick: (event) => { + clickLocation = event.location; + getTooltip().option('onShowing', e => { e.cancel = true; }); + } + }] }); + positionMarker(); + getMarker().click(); + assert.deepEqual(clickLocation, location, 'sidebar can use the coordinates'); + assert.notOk(getTooltip().option('visible'), 'onShowing cancels the popup'); }); - QUnit.test('Escape on a marker with a closed tooltip leaves other tooltips open', async function(assert) { - await createMap({ - markers: [ - { location, tooltip: { text: 'First', isShown: true } }, - { location, tooltip: { text: 'Second', isShown: true } }, - { location, tooltip: 'Third' } - ] - }); - const [firstTooltip, secondTooltip, thirdTooltip] = getPopovers(); - const marker = openLayersMock.addedOverlays[2].options.element; - marker.focus(); - assert.strictEqual(document.activeElement, marker, 'the marker with the closed tooltip has focus'); - - marker.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); - - assert.ok(firstTooltip.option('visible'), 'first tooltip stays open'); - assert.ok(secondTooltip.option('visible'), 'the last opened tooltip stays open'); - assert.notOk(thirdTooltip.option('visible'), 'the focused marker tooltip stays closed'); - assert.strictEqual(document.activeElement, marker, 'focus stays on the marker'); + QUnit.test('public contentTemplate and hide support a user-provided Close button', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + const tooltip = getTooltip(); + tooltip.option('contentTemplate', () => $('' }] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + const popup = getContent(tooltip).parentElement; + const button = popup.querySelector('button'); + button.focus(); + $(getMarker()).css('top', -1000); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(tooltip.option('visible'), 'remains logically open'); + assert.ok(popup.inert, 'offscreen popup is inert'); + assert.strictEqual(document.activeElement, getOpenLayersMapTarget(), 'focus moves to the map without scrolling'); + positionMarker(); + openLayersMock.mapInstance.trigger('postrender'); + assert.notOk(popup.inert, 'returning content can receive focus'); }); - QUnit.test('disabled markers cannot open tooltips', async function(assert) { - const onClick = sinon.spy(); - const map = await createMap({ markers: [{ location, tooltip: 'Start', onClick }] }); + QUnit.test('disabled and focusStateEnabled cover content added with public options', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + const tooltip = getTooltip(); + tooltip.option('contentTemplate', () => $('' }], onClick }); + positionMarker(); + getMarker().click(); + const popup = getContent(getTooltip()).parentElement; + const mapTarget = getOpenLayersMapTarget(); + const wheel = sinon.spy(); + const click = sinon.spy(); + mapTarget.addEventListener('wheel', wheel); + mapTarget.addEventListener('click', click); + popup.querySelector('button').click(); + popup.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); + openLayersMock.mapInstance.trigger('click', { coordinate: [-73980, 40740], originalEvent: { target: popup } }); + assert.ok(onClick.notCalled, 'engine event is filtered'); + assert.ok(click.notCalled, 'DOM click is stopped'); + assert.ok(wheel.calledOnce, 'wheel reaches the map'); + mapTarget.removeEventListener('wheel', wheel); + mapTarget.removeEventListener('click', click); }); [false, true].forEach(rtlEnabled => { - QUnit.test(`tooltip keyboard, ARIA and removal work inside Shadow DOM (rtlEnabled: ${rtlEnabled})`, function(assert) { + QUnit.test(`tooltip keyboard activation and cleanup work in Shadow DOM (RTL: ${rtlEnabled})`, function(assert) { const host = document.createElement('div'); - const shadowRoot = host.attachShadow({ mode: 'open' }); + document.getElementById('qunit-fixture').appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); const container = document.createElement('div'); - shadowRoot.appendChild(container); - $('#qunit-fixture').append(host); - const engineMap = createOpenLayersEngine(openLayersMock).createMap(container); + Object.assign(container.style, { width: '600px', height: '400px' }); + container.tabIndex = 0; + shadow.appendChild(container); + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(container, { center: location, zoom: 12 }); try { - const marker = engineMap.addMarker({ location, rtlEnabled, tooltip: { text: 'Start', visible: true } }); - const markerElement = marker.originalMarker.options.element; - const tooltip = MarkerPopover.getInstance(shadowRoot.querySelector('.dx-popover')); - const popup = getContent(tooltip).parentElement; - const close = popup.querySelector('.dx-map-marker-tooltip-close'); - assert.strictEqual(popup.getRootNode(), shadowRoot, 'popup remains in the marker Shadow Root'); - assert.strictEqual(shadowRoot.getElementById(markerElement.getAttribute('aria-controls')), popup, 'aria-controls resolves in the same tree'); - assert.strictEqual(shadowRoot.getElementById(popup.getAttribute('aria-labelledby')).textContent, 'Start', 'the accessible name resolves in the same tree'); - assert.strictEqual(tooltip.option('rtlEnabled'), rtlEnabled, 'popup receives the map direction'); - - markerElement.focus(); - markerElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true, cancelable: true })); - assert.strictEqual(shadowRoot.activeElement, close, 'keyboard activation focuses Close'); - close.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true, cancelable: true })); - assert.notOk(tooltip.option('visible'), 'Escape closes the shadow popup'); - assert.strictEqual(shadowRoot.activeElement, markerElement, 'Escape restores marker focus'); - - markerElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true, cancelable: true })); - assert.strictEqual(shadowRoot.activeElement, close, 'focus returns to the reopened tooltip'); + const marker = engineMap.addMarker({ location, tooltip: { text: 'Start', visible: false }, rtlEnabled }); + const element = marker.originalMarker.options.element; + positionMarker(element); + element.focus(); + element.click(); + const [tooltip] = getPopovers(shadow); + assert.ok(tooltip.option('visible'), 'popover opens'); + assert.strictEqual(shadow.activeElement, element, 'focus stays on the marker'); + assert.strictEqual(tooltip.option('rtlEnabled'), rtlEnabled, 'direction is forwarded'); + element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true })); + assert.notOk(tooltip.option('visible'), 'Escape reaches the stock component'); marker.dispose(); - assert.strictEqual(shadowRoot.activeElement, container, 'removal restores focus to the shadow map target'); - assert.notOk(shadowRoot.querySelector('.dx-map-marker-popover'), 'removal leaves no popup behind'); + assert.strictEqual(getPopovers(shadow).length, 0, 'removal cleans up the widget'); } finally { engineMap.dispose(); - $(host).remove(); + host.remove(); } }); }); - QUnit.test('popup clicks do not reach the map but wheel events do', async function(assert) { - const onClick = sinon.spy(); - await createMap({ markers: [{ location, tooltip: { text: 'More', isShown: true } }], onClick }); - const tooltipElement = getContent(getTooltip()).parentElement; - const mapElement = getOpenLayersMapTarget(); - const click = sinon.spy(); - const wheel = sinon.spy(); - mapElement.addEventListener('click', click); - mapElement.addEventListener('wheel', wheel); - tooltipElement.querySelector('a').dispatchEvent(new MouseEvent('click', { bubbles: true })); - tooltipElement.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); - openLayersMock.mapInstance.trigger('click', { - coordinate: [-73980, 40740], originalEvent: { target: tooltipElement } - }); - assert.ok(click.notCalled, 'DOM click does not bubble to the map'); - assert.ok(onClick.notCalled, 'OpenLayers click is filtered for popup content'); - assert.ok(wheel.calledOnce, 'wheel can reach map interactions'); - $(tooltipElement.querySelector('.dx-map-marker-tooltip-close')).trigger('dxclick'); - assert.notOk(getTooltip().option('visible'), 'close button hides the tooltip'); - mapElement.removeEventListener('click', click); - mapElement.removeEventListener('wheel', wheel); - }); - - [ - { iconSrc: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=' }, - { html: 'Stop', htmlOffset: { left: 4, top: 10 } }, - { html: '' } - ].forEach(options => { - QUnit.test(`tooltip supports a custom marker (${options.iconSrc ? 'image' : options.html})`, async function(assert) { - openLayersMock.getOverlayRect = () => ({ height: 60, width: 40 }); - await createMap({ markers: [{ location, ...options, tooltip: { text: 'Stop', isShown: true } }], rtlEnabled: true }); - const tooltip = getTooltip(); - assert.strictEqual(tooltip.option('target'), getMarker().options.element, 'popover targets the custom marker'); - assert.ok(tooltip.option('rtlEnabled'), 'popup inherits RTL'); - assert.ok(tooltip.option('visible'), 'tooltip is visible'); - }); - }); - - QUnit.test('tooltip position follows resized images and wrapped map views', async function(assert) { - let height = 40; - openLayersMock.getOverlayRect = () => ({ height, width: 25 }); - await createMap({ markers: [{ location, iconSrc: 'marker.png', tooltip: { text: 'Start', isShown: true } }] }); + QUnit.test('removing a focused tooltip focuses the map and detaches render synchronization', async function(assert) { + const options = { location, tooltip: '' }; + const map = await createMap({ markers: [options] }); + positionMarker(); + getMarker().click(); const tooltip = getTooltip(); - const repaint = sinon.spy(tooltip, 'repaint'); - height = 70; - triggerResize(getMarker().options.element); - assert.ok(repaint.called, 'loaded image size updates popover positioning'); - repaint.resetHistory(); - openLayersMock.viewExtent = [285900, 40600, 286200, 40900]; - openLayersMock.mapInstance.getView().setCenter([286020, 40740]); - assert.ok(repaint.called, 'the wrapped view updates popover positioning'); - assert.strictEqual(tooltip.option('target'), getMarker().options.element, 'popover stays attached to the same marker'); - }); - - QUnit.test('popup keyboard access respects map options independently of marker visibility', async function(assert) { - const map = await createMap({ markers: [{ location, tooltip: { text: 'More', isShown: true } }] }); - const popup = getContent(getTooltip()).parentElement; - const close = popup.querySelector('.dx-map-marker-tooltip-close'); - const link = popup.querySelector('a'); - map.option('disabled', true); - await map._lastAsyncAction; - assert.strictEqual(close.tabIndex, -1, 'disabled close action is outside the tab order'); - assert.strictEqual(link.tabIndex, -1, 'disabled content is outside the tab order'); - assert.ok(getOpenLayersMapTarget().hasAttribute('inert'), 'map inert covers popup content'); - map.option({ disabled: false, focusStateEnabled: false }); - await map._lastAsyncAction; - assert.strictEqual(close.tabIndex, -1, 'focusStateEnabled false applies to the popup'); - map.option('focusStateEnabled', true); - await map._lastAsyncAction; - assert.strictEqual(close.tabIndex, 0, 'close button focusability is restored'); - close.focus(); - openLayersMock.viewExtent = [0, 0, 100, 100]; - openLayersMock.mapInstance.trigger('moveend'); - assert.strictEqual(getMarker().options.element.tabIndex, -1, 'offscreen marker is outside the tab order'); - assert.strictEqual(close.tabIndex, 0, 'the visible popup remains in the tab order'); - assert.strictEqual(document.activeElement, close, 'the visible popup retains focus'); - close.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); - assert.notOk(getTooltip().option('visible'), 'Escape closes the popup'); - assert.strictEqual(document.activeElement, getOpenLayersMapTarget(), 'focus returns to the map when its marker is offscreen'); - }); - - QUnit.test('runtime tooltip updates, removal and disposal clean up widgets and handlers', async function(assert) { - const marker = { location, tooltip: 'Old text' }; - const map = await createMap({ markers: [marker] }); - const oldMarker = getMarker(); - const oldHost = $('#map .dx-popover')[0]; - const oldDispose = sinon.spy(getTooltip(), 'dispose'); - map.option('markers[0].tooltip', { text: 'New text', isShown: true }); - await map._lastAsyncAction; - assert.ok(oldDispose.calledOnce, 'old popup is disposed on update'); - assert.notOk(oldHost.isConnected, 'old widget host is removed'); - const currentTooltip = getTooltip(); - oldMarker.options.element.click(); - assert.strictEqual(getPopovers().length, 1, 'detached marker cannot recreate the old popup'); - assert.strictEqual(getContent(currentTooltip).querySelector('.dx-map-marker-tooltip').textContent, 'New text', 'new tooltip content is rendered'); - assert.ok(currentTooltip.option('visible'), 'updated isShown is applied'); - const currentDispose = sinon.spy(currentTooltip, 'dispose'); - await map.removeMarker(map.option('markers')[0]); - assert.ok(currentDispose.calledOnce, 'removeMarker disposes the popup'); - assert.strictEqual(getPopovers().length, 0, 'no popup host remains'); - await map.addMarker({ location, tooltip: 'Added later' }); - const latestDispose = sinon.spy(getTooltip(), 'dispose'); - map.dispose(); - assert.ok(latestDispose.calledOnce, 'map disposal disposes the popup'); - assert.strictEqual(document.querySelectorAll('.dx-map-marker-popover').length, 0, 'no popup wrapper remains'); - assert.strictEqual(resizeObserverCallbacks.size, 0, 'no marker resize observer is retained'); - }); - QUnit.test('existing and newly added markers use dxPopover', async function(assert) { - const map = await createMap({ markers: [{ location, tooltip: 'First' }, { location, tooltip: 'Second' }] }); - assert.strictEqual(getPopovers().length, 2, 'both tooltips use dxPopover'); - assert.strictEqual(openLayersMock.addedOverlays.length, 2, 'OpenLayers overlays are used only for markers'); - await map.addMarker({ location, tooltip: 'Third' }); - assert.strictEqual(getPopovers().length, 3, 'addMarker also creates a popover'); - openLayersMock.addedOverlays[0].options.element.click(); - assert.ok(getPopovers()[0].option('visible'), 'marker activation opens the popover'); - }); - - [false, true].forEach(rtlEnabled => { - QUnit.test(`initial visibility preserves HTML without taking focus (rtlEnabled: ${rtlEnabled})`, async function(assert) { - const activeElement = document.activeElement; - await createMap({ markers: [{ location, tooltip: { text: 'Start', isShown: true } }], rtlEnabled }); - const popover = getPopovers()[0]; - assert.ok(popover.option('visible'), 'isShown is applied'); - assert.strictEqual(popover.option('rtlEnabled'), rtlEnabled, 'map direction reaches dxPopover'); - assert.strictEqual(getMarker().options.element.getAttribute('dir'), rtlEnabled ? 'rtl' : 'ltr', 'marker content keeps its direction'); - assert.strictEqual(getContent(popover).querySelector('b').textContent, 'Start', 'HTML is rendered'); - assert.strictEqual(document.activeElement, activeElement, 'initial popup does not steal focus'); - assert.strictEqual(getContent(popover).parentElement.getAttribute('role'), 'dialog', 'popup has dialog semantics'); - }); - }); - - QUnit.test('compact content preserves keyboard activation, Escape and the close action', async function(assert) { - await createMap({ markers: [{ location, tooltip: 'Start' }] }); - const marker = openLayersMock.addedOverlays[0].options.element; - const popover = getPopovers()[0]; - marker.focus(); - marker.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); - const content = getContent(popover); - const close = content.querySelector('.dx-map-marker-tooltip-close'); - assert.notOk(popover.option('showTitle'), 'there is no empty title area'); - assert.ok(close.classList.contains('dx-button'), 'Close uses dxButton'); - assert.ok(close.querySelector('.dx-icon-close'), 'Close uses the existing icon'); - assert.strictEqual(close.getAttribute('aria-label'), localization.formatMessage('Close'), 'Close has an accessible name'); - assert.strictEqual(document.activeElement, close, 'keyboard activation focuses Close'); - close.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); - assert.notOk(popover.option('visible'), 'Escape closes the popover'); - assert.strictEqual(document.activeElement, marker, 'focus returns to the marker'); + getContent(tooltip).querySelector('button').focus(); + const position = sinon.spy(tooltip, '_renderPosition'); + await map.removeMarker(options); + position.resetHistory(); openLayersMock.mapInstance.trigger('postrender'); - assert.notOk(popover.option('visible'), 'Escape remains closed after rendering'); - marker.click(); - $(close).trigger('dxclick'); - assert.notOk(popover.option('visible'), 'close button works'); - }); - - QUnit.test('close dxButton supports Enter and Space', async function(assert) { - await createMap({ markers: [{ location, tooltip: 'Start' }] }); - const marker = openLayersMock.addedOverlays[0].options.element; - const popover = getPopovers()[0]; - const close = getContent(popover).querySelector('.dx-map-marker-tooltip-close'); - ['Enter', ' '].forEach(key => { - marker.click(); - close.focus(); - close.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); - assert.notOk(popover.option('visible'), `${key === ' ' ? 'Space' : key} activates Close`); - assert.strictEqual(document.activeElement, marker, 'focus returns to the marker'); - document.activeElement.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true, cancelable: true })); - openLayersMock.mapInstance.trigger('postrender'); - assert.notOk(popover.option('visible'), 'releasing the key after focus returns does not reopen the popover'); - }); - }); - - QUnit.test('popup events, disabled and focusStateEnabled remain scoped to the map', async function(assert) { - const onClick = sinon.spy(); - const map = await createMap({ markers: [{ location, tooltip: { text: 'More', isShown: true } }], onClick }); - const element = getContent(getPopovers()[0]).parentElement; - const link = element.querySelector('a'); - const wheel = sinon.spy(); - const target = getOpenLayersMapTarget(); - target.addEventListener('wheel', wheel); - link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - element.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); - openLayersMock.mapInstance.trigger('click', { coordinate: [-73980, 40740], originalEvent: { target: element } }); - assert.ok(onClick.notCalled, 'popover click is not a map click'); - assert.ok(wheel.calledOnce, 'wheel reaches the map'); - map.option('disabled', true); - await map._lastAsyncAction; - assert.ok(target.hasAttribute('inert'), 'map inert includes dxPopover'); - assert.strictEqual(link.tabIndex, -1, 'disabled links are outside tab order'); - map.option({ disabled: false, focusStateEnabled: false }); - await map._lastAsyncAction; - assert.strictEqual(link.tabIndex, -1, 'focusStateEnabled also covers dxPopover'); - target.removeEventListener('wheel', wheel); - }); - - QUnit.test('render synchronization is removed when the marker is removed', async function(assert) { - const marker = { location, tooltip: { text: 'Start', isShown: true } }; - const map = await createMap({ markers: [marker] }); - const popover = getPopovers()[0]; - const repaint = sinon.spy(popover, 'repaint'); - openLayersMock.mapInstance.trigger('postrender'); - assert.ok(repaint.calledOnce, 'native popover positioning runs after map rendering'); - await map.removeMarker(marker); - repaint.resetHistory(); - openLayersMock.mapInstance.trigger('postrender'); - assert.ok(repaint.notCalled, 'removed popup no longer receives map renders'); + assert.strictEqual(document.activeElement, getOpenLayersMapTarget(), 'removal restores focus'); + assert.ok(position.notCalled, 'render subscription is removed'); assert.strictEqual(getPopovers().length, 0, 'widget host is removed'); - assert.strictEqual(document.querySelectorAll('.dx-map-marker-popover').length, 0, 'popup wrapper is removed'); - }); - - QUnit.test('an open popover stays visible beyond marker viewport boundaries without stealing focus', async function(assert) { - await createMap({ markers: [{ location, tooltip: 'Start' }] }); - const marker = openLayersMock.addedOverlays[0].options.element; - const popover = getPopovers()[0]; - marker.focus(); - marker.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); - assert.ok(popover.option('visible'), 'keyboard activation opens the popover'); - const input = $('').appendTo('#qunit-fixture')[0]; - input.focus(); - const extent = openLayersMock.viewExtent; - openLayersMock.viewExtent = [0, 0, 1, 1]; - openLayersMock.mapInstance.trigger('postrender'); - assert.ok(popover.option('visible'), 'popover stays visible when its marker leaves the viewport'); - assert.strictEqual(marker.getAttribute('aria-expanded'), 'true', 'the visible popup remains expanded'); - assert.strictEqual(document.activeElement, input, 'leaving the viewport does not move focus'); - openLayersMock.viewExtent = extent; - openLayersMock.mapInstance.trigger('postrender'); - assert.ok(popover.option('visible'), 'popover remains visible when the marker returns'); - assert.strictEqual(marker.getAttribute('aria-expanded'), 'true', 'visible popup is reported as expanded'); - assert.strictEqual(document.activeElement, input, 'returning popup does not repeat the keyboard focus request'); - $(getContent(popover).querySelector('.dx-map-marker-tooltip-close')).trigger('dxclick'); - openLayersMock.viewExtent = [0, 0, 1, 1]; - openLayersMock.mapInstance.trigger('postrender'); - openLayersMock.viewExtent = extent; - openLayersMock.mapInstance.trigger('postrender'); - assert.notOk(popover.option('visible'), 'Close is preserved after the marker leaves and returns'); }); - QUnit.test('popover flips near the map edge and leaves it without resizing or fitting', async function(assert) { - await createMap({ width: 600, height: 400, markers: [{ location, tooltip: 'Start' }] }); - const marker = getMarker().options.element; - const popover = getTooltip(); - const popup = getContent(popover).parentElement; - const boundary = getOpenLayersMapTarget().getBoundingClientRect(); - $(marker).css({ position: 'absolute', left: 300, top: 200 }); - marker.click(); - const initial = popup.getBoundingClientRect(); - assert.ok(initial.height > 0, 'tooltip has content'); - $(marker).css('top', 0); - openLayersMock.mapInstance.trigger('postrender'); - assert.ok(popup.getBoundingClientRect().top >= marker.getBoundingClientRect().bottom, 'tooltip flips below the marker near the top'); - $(marker).css('top', -90); - openLayersMock.mapInstance.trigger('postrender'); - const outside = popup.getBoundingClientRect(); - assert.ok(popover.option('visible'), 'leaving the boundary does not close the tooltip'); - assert.ok(outside.top < boundary.top, 'tooltip follows its marker outside the map instead of fitting inside'); - assert.strictEqual(outside.height, initial.height, 'crossing the boundary does not shrink the tooltip'); - assert.strictEqual(outside.width, initial.width, 'crossing the boundary does not change the width'); - }); - - QUnit.test('initially shown popover is visible even when its marker is offscreen', async function(assert) { - const extent = openLayersMock.viewExtent; - openLayersMock.viewExtent = [0, 0, 1, 1]; - const activeElement = document.activeElement; - await createMap({ markers: [{ location, tooltip: { text: 'Start', isShown: true } }] }); - const popover = getPopovers()[0]; - assert.ok(popover.option('visible'), 'isShown displays the popup independently of marker visibility'); - assert.strictEqual(document.activeElement, activeElement, 'initial showing does not take focus'); - openLayersMock.viewExtent = extent; - openLayersMock.mapInstance.trigger('postrender'); - assert.ok(popover.option('visible'), 'popover stays visible when the marker enters the viewport'); - assert.strictEqual(document.activeElement, activeElement, 'automatic showing does not take focus'); + QUnit.test('runtime tooltip updates and map disposal do not retain old popovers', async function(assert) { + const map = await createMap({ markers: [{ location, tooltip: 'Old' }] }); + const dispose = sinon.spy(getTooltip(), 'dispose'); + map.option('markers[0].tooltip', { text: 'New', isShown: true }); + await map._lastAsyncAction; + assert.ok(dispose.calledOnce, 'old component is disposed'); + assert.strictEqual(getPopovers().length, 1, 'one current component'); + assert.strictEqual(getContent(getTooltip()).textContent, 'New', 'new content is rendered'); + map.dispose(); + assert.strictEqual(getPopovers().length, 0, 'no popover remains after disposal'); }); - }); QUnit.module('OSM: routes', moduleConfig, () => { From e08255ea1439a462827c0deba3141b0de62d92b9 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 18 Sep 2026 11:39:28 +0400 Subject: [PATCH 6/7] Map: OSM provider - Skip render updates for hidden tooltips --- ...provider.dynamic.osm.openlayers.tooltip.ts | 16 +++--- .../mapParts/osmTests.js | 55 +++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts index dd4ca1926fe5..8e7966f6dc96 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts @@ -135,13 +135,15 @@ export class OpenLayersMarkerTooltip { }; readonly syncPosition = (): void => { - if (this._popover.option('visible')) { - this._positioning = true; - try { - this._popover._renderPosition(false); - } finally { - this._positioning = false; - } + if (!this._popover.option('visible')) { + return; + } + + this._positioning = true; + try { + this._popover._renderPosition(false); + } finally { + this._positioning = false; } this._syncFocusState(); }; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index abec01880def..2c9c4c1b29a8 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -2164,6 +2164,35 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { assert.strictEqual(popup.getBoundingClientRect().width, initial.width, 'width is preserved'); }); + [false, true].forEach(shownBefore => { + QUnit.test(`postrender skips DOM work for a hidden tooltip (shown before: ${shownBefore})`, async function(assert) { + await createMap({ markers: [{ location, tooltip: '' }] }); + positionMarker(); + const marker = getMarker(); + const tooltip = getTooltip(); + if(shownBefore) { + marker.click(); + await Promise.resolve(); + await tooltip.hide(); + } + const popup = getContent(tooltip).parentElement; + const position = sinon.spy(tooltip, '_renderPosition'); + const boundaryRect = sinon.spy(getOpenLayersMapTarget(), 'getBoundingClientRect'); + const markerRect = sinon.spy(marker, 'getBoundingClientRect'); + const popupRect = sinon.spy(popup, 'getBoundingClientRect'); + const focusTargets = sinon.spy(popup, 'querySelectorAll'); + + openLayersMock.mapInstance.trigger('postrender'); + + assert.notOk(tooltip.option('visible'), 'tooltip remains hidden'); + assert.ok(position.notCalled, 'position is not recalculated'); + assert.ok(boundaryRect.notCalled, 'map bounds are not measured'); + assert.ok(markerRect.notCalled, 'marker bounds are not measured'); + assert.ok(popupRect.notCalled, 'popup bounds are not measured'); + assert.ok(focusTargets.notCalled, 'focusable content is not queried'); + }); + }); + QUnit.test('postrender updates the position without repainting or recalculating dimensions', async function(assert) { await createMap({ markers: [{ location, tooltip: { text: 'Start', isShown: true } }] }); const tooltip = getTooltip(); @@ -2226,6 +2255,32 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { assert.notOk(popup.inert, 'returning content can receive focus'); }); + QUnit.test('a marker with a closed offscreen tooltip becomes interactive after returning to the viewport', async function(assert) { + await createMap({ markers: [{ location, tooltip: '' }] }); + positionMarker(); + const marker = getMarker(); + const tooltip = getTooltip(); + marker.click(); + await Promise.resolve(); + positionMarker(marker, -1000); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(marker.inert, 'offscreen marker cannot receive focus'); + await tooltip.hide(); + + positionMarker(); + openLayersMock.mapInstance.trigger('postrender'); + openLayersMock.mapInstance.trigger('moveend'); + + assert.notOk(tooltip.option('visible'), 'moving the map does not reopen the tooltip'); + assert.notOk(marker.inert, 'marker accessibility is restored at the end of movement'); + assert.strictEqual(marker.tabIndex, 0, 'marker returns to the tab order'); + marker.focus(); + assert.strictEqual(document.activeElement, marker, 'marker can receive focus'); + marker.click(); + assert.ok(tooltip.option('visible'), 'tooltip can be reopened'); + assert.notOk(getContent(tooltip).parentElement.inert, 'reopened content is interactive'); + }); + QUnit.test('disabled and focusStateEnabled cover content added with public options', async function(assert) { const map = await createMap({ markers: [{ location, tooltip: 'Start' }] }); positionMarker(); From bb82862f54604e37d50be9715972abd623e6ad18 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 18 Sep 2026 12:08:55 +0400 Subject: [PATCH 7/7] Map: OSM provider - Preserve focus settings when customizing tooltips --- ...provider.dynamic.osm.openlayers.tooltip.ts | 10 +--- .../mapParts/osmTests.js | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts index 8e7966f6dc96..b68f69b84290 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts @@ -79,7 +79,7 @@ export class OpenLayersMarkerTooltip { wrapperAttr: { class: POPOVER_CLASS }, }) as MarkerPopover; this.element = $(this._popover.content()).parent().get(0) as HTMLElement; - this._popover.on('showing', this._prepareShowing); + this._popover.on('showing', this._syncFocusState); this._popover.on('positioned', this._restoreContentSize); this._popover.on('positioned', this._syncFocusState); this._popover.on('shown', this._syncFocusState); @@ -100,6 +100,7 @@ export class OpenLayersMarkerTooltip { setFocusEnabled(enabled: boolean): void { if (enabled !== this._focusEnabled) { this._focusEnabled = enabled; + this._popover.option('_preventDialogContainerFocus', !enabled); const focusEnabled = enabled && this.element.getAttribute('role') === 'dialog'; this._popover.option({ focusStateEnabled: focusEnabled, @@ -109,13 +110,6 @@ export class OpenLayersMarkerTooltip { this._syncFocusState(); } - private readonly _prepareShowing = (): void => { - if (!this._focusEnabled) { - this._popover.option({ focusStateEnabled: false, tabFocusLoopEnabled: false }); - } - this._syncFocusState(); - }; - private readonly _restoreContentSize = (): Promise | undefined => { if (this._positioning || this._positionUpdatePending) { return undefined; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js index 2c9c4c1b29a8..877efc1140a2 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -2046,10 +2046,33 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { assert.strictEqual(document.activeElement, focusStateEnabled ? close : input, 'native autofocus respects the map'); assert.strictEqual(tooltip.option('tabFocusLoopEnabled'), focusStateEnabled, 'the focus loop respects the map'); await tooltip.hide(); + assert.strictEqual(document.activeElement, focusStateEnabled ? getMarker() : input, 'hiding respects the map focus setting'); } }); }); + [false, true].forEach(focusStateEnabled => { + [ + { name: 'title and Close button', options: { title: 'Details', showTitle: true, showCloseButton: true } }, + { name: 'toolbar items', options: { toolbarItems: [{ widget: 'dxButton', options: { text: 'Details' } }] } } + ].forEach(({ name, options }) => { + QUnit.test(`customizing an open tooltip with ${name} respects focusStateEnabled: ${focusStateEnabled}`, async function(assert) { + await createMap({ focusStateEnabled, markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + tooltip.option(options); + + assert.ok(tooltip.option('visible'), 'the tooltip remains open'); + assert.strictEqual(tooltip.option('focusStateEnabled'), focusStateEnabled, 'native focus respects the map'); + assert.strictEqual(tooltip.option('tabFocusLoopEnabled'), focusStateEnabled, 'the focus loop respects the map'); + await Promise.resolve(); + const button = getContent(tooltip).parentElement.querySelector('.dx-button'); + assert.strictEqual(button.tabIndex, focusStateEnabled ? 0 : -1, 'the new button respects tab navigation'); + }); + }); + }); + QUnit.test('dialog keyboard access is restored after changing focusStateEnabled', async function(assert) { const map = await createMap({ focusStateEnabled: false, markers: [{ location, tooltip: 'Start' }] }); positionMarker(); @@ -2068,6 +2091,34 @@ QUnit.module('OSM: marker tooltips', moduleConfig, () => { assert.strictEqual(document.activeElement, close, 'native autofocus works after enabling'); }); + ['focusStateEnabled', 'disabled'].forEach(optionName => { + QUnit.test(`an open dialog follows runtime changes of ${optionName}`, async function(assert) { + const map = await createMap({ focusStateEnabled: true, markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + const tooltip = getTooltip(); + tooltip.option({ title: 'Details', showTitle: true, showCloseButton: true }); + getMarker().click(); + const enabledValue = optionName === 'focusStateEnabled'; + + map.option(optionName, !enabledValue); + await map._lastAsyncAction; + tooltip.option('toolbarItems', [{ widget: 'dxButton', options: { text: 'Details' } }]); + assert.notOk(tooltip.option('focusStateEnabled'), 'native focus stays disabled after customization'); + assert.notOk(tooltip.option('tabFocusLoopEnabled'), 'the focus loop stays disabled after customization'); + + map.option(optionName, enabledValue); + await map._lastAsyncAction; + assert.ok(tooltip.option('focusStateEnabled'), 'native focus is restored'); + assert.ok(tooltip.option('tabFocusLoopEnabled'), 'the focus loop is restored'); + await tooltip.hide(); + getMarker().click(); + const button = getContent(tooltip).parentElement.querySelector('.dx-button'); + assert.strictEqual(document.activeElement, button, 'native autofocus works after restoring focus'); + await tooltip.hide(); + assert.strictEqual(document.activeElement, getMarker(), 'native focus restoration works'); + }); + }); + QUnit.test('tooltip describes the focusable children of an HTML marker', async function(assert) { await createMap({ markers: [{ location,