diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index a6ca9ad15817..566feb0a4cb1 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', }, @@ -329,13 +347,15 @@ export default meta; type Story = StoryObj; -export const Default: Story = { +export const Overview: Story = { args: { autoAdjust: false, centerOnCentralPark: false, controls: true, disabled: false, focusStateEnabled: true, + tooltipsEnabled: true, + tooltipsInitiallyShown: false, rtlEnabled: false, showRoute: true, routeColor: '#0000ff', 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'], 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.tooltip.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts new file mode 100644 index 000000000000..b68f69b84290 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.tooltip.ts @@ -0,0 +1,216 @@ +import { normalizeKeyName } from '@js/common/core/events/utils'; +import domAdapter from '@js/core/dom_adapter'; +import $ from '@js/core/renderer'; +import type { Properties } from '@js/ui/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 type { MapLike } from './provider.dynamic.osm.openlayers.utils'; + +const POPOVER_CLASS = 'dx-map-marker-popover'; +const TOOLTIP_MAX_WIDTH = 280; + +type MarkerPopover = Popover & Pick; + +export class OpenLayersMarkerTooltip { + readonly element: HTMLElement; + + private readonly _host: HTMLElement; + + private readonly _popover: MarkerPopover; + + private readonly _inertElements = new Set(); + + private readonly _tabIndexes = new Map(); + + private _focusEnabled = true; + + private _positioning = false; + + private _positionUpdatePending = false; + + private _disposed = false; + + constructor( + private readonly _map: MapLike, + private readonly _container: Element, + private readonly _marker: HTMLElement, + text: string, + 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.innerHTML = text; + 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: _container, + }, + animation: undefined, + deferRendering: false, + contentTemplate: (): HTMLElement => content, + maxWidth: TOOLTIP_MAX_WIDTH, + showTitle: false, + showCloseButton: false, + hideOnOutsideClick: false, + hideOnParentScroll: false, + rtlEnabled, + elementAttr: { class: POPOVER_CLASS }, + wrapperAttr: { class: POPOVER_CLASS }, + }) as MarkerPopover; + this.element = $(this._popover.content()).parent().get(0) as HTMLElement; + this._popover.on('showing', this._syncFocusState); + 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); + } + + show(): void { + if (!this._disposed) { + this._popover.option('visible', true); + } + } + + 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, + tabFocusLoopEnabled: focusEnabled, + }); + } + this._syncFocusState(); + } + + private readonly _restoreContentSize = (): Promise | undefined => { + if (this._positioning || this._positionUpdatePending) { + return undefined; + } + + this._positionUpdatePending = true; + return Promise.resolve().then(() => { + this._positionUpdatePending = false; + if (this._disposed) { + return; + } + + this._popover._renderDimensions(); + this._popover._setContentHeight(true); + this.syncPosition(); + }); + }; + + readonly syncPosition = (): void => { + if (!this._popover.option('visible')) { + return; + } + + this._positioning = true; + try { + this._popover._renderPosition(false); + } finally { + this._positioning = false; + } + this._syncFocusState(); + }; + + private readonly _stopPropagation = (event: Event): void => event.stopPropagation(); + + private readonly _stopMapKeyPropagation = (event: KeyboardEvent): void => { + const key = normalizeKeyName(event); + if (event.defaultPrevented || (key !== 'escape' && key !== 'tab')) { + event.stopPropagation(); + } + }; + + 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._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 03f58933f37c..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 @@ -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,12 +211,18 @@ 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(); + if ((event as KeyboardEvent).key !== 'Escape' || event.defaultPrevented) { + event.stopPropagation(); + } if (!keyboardInteractive) { return; @@ -223,6 +234,9 @@ class OpenLayersMap implements MapEngineMap { } event.preventDefault(); + if (keyboardEvent.key === ' ') { + spacePressed = true; + } if (keyboardEvent.key === 'Enter' && !keyboardEvent.repeat) { element.click(); } @@ -237,7 +251,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 +266,7 @@ class OpenLayersMap implements MapEngineMap { height = rect.height; width = rect.width; + onSizeChange?.(); if (this._markerSizeRefitEnabled) { this._eventHandlers?.markerSizeChange(); } @@ -273,6 +291,7 @@ class OpenLayersMap implements MapEngineMap { } if (keyReleaseHandler) { element.addEventListener(KEY_RELEASE_EVENT, keyReleaseHandler); + element.addEventListener('blur', blurHandler); } resizeObserverSingleton.observe(element, resizeHandler); @@ -287,6 +306,7 @@ class OpenLayersMap implements MapEngineMap { } if (keyReleaseHandler) { element.removeEventListener(KEY_RELEASE_EVENT, keyReleaseHandler); + element.removeEventListener('blur', blurHandler); } resizeObserverSingleton.unobserve(element); }, @@ -294,12 +314,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,7 +328,20 @@ 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 + ? (): void => { + options.onClick?.(); + tooltip?.show(); + } + : undefined; + const markerElementBinding = this._attachMarkerElementHandlers( + element, + onClick, + () => tooltip?.syncPosition(), + ); this._markerSizeRefitEnabled = true; let disposed = false; @@ -322,13 +353,19 @@ class OpenLayersMap implements MapEngineMap { 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 +374,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 +450,19 @@ 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 { + marker.tooltip?.setFocusEnabled(this._focusEnabled && !this._disabled); 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 +482,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 +500,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 +595,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..877efc1140a2 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 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'; 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,534 @@ 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, + width: 600, + height: 400, + providerConfig: { + tileServer: { url: 'https://tiles.example.com/{z}/{x}/{y}.png', attribution: 'Example' } + }, + ...options, + onReady: ({ component }) => resolve(component) + }); + }); + 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].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'); + } finally { + log.restore(); + } + }); + + 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(); + 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'); + }); + }); + + ['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('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'); + }); + + [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(); + 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(); + 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(); + 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'); + }); + + ['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, + 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: '
' }] }); + positionMarker(); + getMarker().click(); + 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'); + 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 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'); + } finally { + selectBox.dispose(); + } + }); + + 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('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(); + 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(); + const position = sinon.spy(tooltip, '_renderPosition'); + const dimensions = sinon.spy(tooltip, '_renderDimensions'); + const repaint = sinon.spy(tooltip, 'repaint'); + openLayersMock.mapInstance.trigger('postrender'); + assert.ok(position.calledOnceWithExactly(false), 'position only'); + assert.ok(dimensions.notCalled, 'dimensions are not recomputed'); + assert.ok(repaint.notCalled, 'no full repaint'); + }); + + QUnit.test('tooltip position follows a resized image marker', async function(assert) { + await createMap({ markers: [{ + location, + iconSrc: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', + tooltip: 'Start' + }] }); + positionMarker(); + const marker = getMarker(); + $(marker).css({ width: 25, height: 40, transform: 'translateY(-100%)' }); + marker.click(); + const tooltip = getTooltip(); + const popup = getContent(tooltip).parentElement; + const initialTop = popup.getBoundingClientRect().top; + $(marker).css('height', 70); + triggerResize(marker); + assert.strictEqual(popup.getBoundingClientRect().top, initialTop - 30, 'popup follows the changed image bounds'); + assert.strictEqual(tooltip.option('position').of, marker, 'the image remains the position target'); + }); + + QUnit.test('tooltip positioning follows its marker into a wrapped world', async function(assert) { + await createMap({ markers: [{ location, tooltip: 'Start' }] }); + positionMarker(); + getMarker().click(); + const tooltip = getTooltip(); + const position = sinon.spy(tooltip, '_renderPosition'); + openLayersMock.viewExtent = [285900, 40600, 286200, 40900]; + openLayersMock.mapInstance.getView().setCenter([286020, 40740]); + assert.deepEqual(openLayersMock.addedOverlays[0].options.position, [286020, 40740], 'marker moves to the adjacent world'); + assert.ok(position.calledWithExactly(false), 'popup position is updated without resizing'); + assert.strictEqual(tooltip.option('target'), getMarker(), 'the same marker remains the target'); + }); + + QUnit.test('an open offscreen popover stays open but its controls cannot scroll the map on focus', async function(assert) { + await createMap({ markers: [{ location, tooltip: '' }] }); + 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('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(); + 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 activation and cleanup work in Shadow DOM (RTL: ${rtlEnabled})`, function(assert) { + const host = document.createElement('div'); + document.getElementById('qunit-fixture').appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + const container = document.createElement('div'); + 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, 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(getPopovers(shadow).length, 0, 'removal cleans up the widget'); + } finally { + engineMap.dispose(); + host.remove(); + } + }); + }); + + 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(); + getContent(tooltip).querySelector('button').focus(); + const position = sinon.spy(tooltip, '_renderPosition'); + await map.removeMarker(options); + position.resetHistory(); + openLayersMock.mapInstance.trigger('postrender'); + 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'); + }); + + 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, () => { const tileServer = { url: 'https://tiles.example.com/{z}/{x}/{y}.png',