From f7f20c8e87c428474e389b82ea45d101e2e63fc5 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Thu, 27 Aug 2026 14:12:37 +0300 Subject: [PATCH 01/11] Map: synchronize OSM viewport and interactions --- .../devextreme/js/__internal/ui/map/map.ts | 6 + .../ui/map/provider.dynamic.osm.engine.ts | 24 ++ .../ui/map/provider.dynamic.osm.openlayers.ts | 307 ++++++++++++++++-- .../__internal/ui/map/provider.dynamic.osm.ts | 72 +++- .../js/__internal/ui/map/provider.ts | 4 + .../js/ui/map/openlayers.register.js | 14 +- 6 files changed, 401 insertions(+), 26 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/map/map.ts b/packages/devextreme/js/__internal/ui/map/map.ts index 6053075d8867..3d2adb2f057a 100644 --- a/packages/devextreme/js/__internal/ui/map/map.ts +++ b/packages/devextreme/js/__internal/ui/map/map.ts @@ -267,6 +267,12 @@ class Map extends Widget { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._queueAsyncAction('updateDisabled'); break; + case 'focusStateEnabled': + case 'tabIndex': + super._optionChanged(args); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._queueAsyncAction('updateFocus'); + break; case 'width': case 'height': super._optionChanged(args); 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 f64d86162337..b438bb68018d 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 @@ -12,10 +12,34 @@ export interface MapEngineSetViewOptions { zoom?: number; } +export interface MapEngineBounds { + northEast: MapLocation; + southWest: MapLocation; +} + +export interface MapEngineViewState extends MapEngineSetViewOptions { + bounds?: MapEngineBounds; +} + +export interface MapEngineClickEvent { + event?: Event; + location: MapLocation; +} + +export interface MapEngineEventHandlers { + click: (event: MapEngineClickEvent) => void; + viewChange: (view: MapEngineViewState) => void; +} + export interface MapEngineMap { readonly originalMap: unknown; + attachHandlers: (handlers: MapEngineEventHandlers) => void; dispose: () => void; + fitBounds: (bounds: MapEngineBounds) => void; replaceTileLayer: (options: MapEngineTileLayerOptions) => void; + setControls: (visible: boolean) => void; + setDisabled: (disabled: boolean) => void; + setFocus: (enabled: boolean, tabIndex: number) => void; setView: (options: MapEngineSetViewOptions) => void; updateDimensions: () => void; } 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 d27f2169c52d..9e2d02b2536c 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 @@ -2,15 +2,39 @@ import type { MapLocation } from '@js/ui/map'; import type { MapEngine, + MapEngineBounds, + MapEngineClickEvent, + MapEngineEventHandlers, MapEngineMap, MapEngineSetViewOptions, MapEngineTileLayerOptions, + MapEngineViewState, } from './provider.dynamic.osm.engine'; type Options = Record; type Coordinate = [number, number]; +type Extent = [number, number, number, number]; + +const GEOGRAPHIC_PROJECTION = 'EPSG:4326'; +const DEFAULT_VIEW_PROJECTION = 'EPSG:3857'; + +type ControlLike = object; + +interface InteractionLike { + getActive: () => boolean; + setActive: (active: boolean) => void; +} + +interface CollectionLike { + forEach: (callback: (item: T) => void) => void; +} interface ViewLike { + calculateExtent: () => Extent; + fit: (extent: Extent) => void; + getCenter: () => Coordinate | undefined; + getProjection: () => unknown; + getZoom: () => number | undefined; setCenter: (center: Coordinate) => void; setZoom: (zoom: number) => void; } @@ -20,17 +44,28 @@ interface TileLayerLike { } interface MapLike { + addControl: (control: ControlLike) => void; addLayer: (layer: unknown) => void; + getInteractions: () => CollectionLike; getView: () => ViewLike; + on: (type: string, listener: (event: unknown) => void) => void; + removeControl: (control: ControlLike) => void; removeLayer: (layer: unknown) => void; setTarget: (target?: Element) => void; + un: (type: string, listener: (event: unknown) => void) => void; updateSize: () => void; } +interface MapBrowserEventLike { + coordinate?: Coordinate; + originalEvent?: Event; +} + interface OpenLayersApi { Map: new (options: Options) => MapLike; View: new (options: Options) => ViewLike; control: { + Zoom: new () => ControlLike; defaults: { defaults: (options?: Options) => unknown; }; @@ -44,7 +79,10 @@ interface OpenLayersApi { Tile: new (options: Options) => TileLayerLike; }; proj: { - fromLonLat: (coordinate: Coordinate) => Coordinate; + getUserProjection: () => unknown | null; + toLonLat: (coordinate: Coordinate, projection?: unknown) => Coordinate; + transform: (coordinate: Coordinate, source: unknown, destination: unknown) => Coordinate; + transformExtent: (extent: Extent, source: unknown, destination: unknown) => Extent; }; source: { ImageTile: new (options: Options) => unknown; @@ -67,36 +105,88 @@ const isOpenLayersApi = (api: unknown): api is OpenLayersApi => { return typeof api.Map === 'function' && typeof api.View === 'function' && isRecord(api.control) + && hasFunction(api.control, 'Zoom') && isRecord(api.control.defaults) && hasFunction(api.control.defaults, 'defaults') && isRecord(api.interaction) && isRecord(api.interaction.defaults) && hasFunction(api.interaction.defaults, 'defaults') && hasFunction(api.layer, 'Tile') - && hasFunction(api.proj, 'fromLonLat') + && hasFunction(api.proj, 'getUserProjection') + && hasFunction(api.proj, 'toLonLat') + && hasFunction(api.proj, 'transform') + && hasFunction(api.proj, 'transformExtent') && hasFunction(api.source, 'ImageTile'); }; -const toCoordinate = (api: OpenLayersApi, location: MapLocation): Coordinate => ( - api.proj.fromLonLat([location.lng, location.lat]) +const getCoordinateProjection = ( + api: OpenLayersApi, + viewProjection: unknown, +): unknown => api.proj.getUserProjection() ?? viewProjection; + +const areCoordinatesEqual = ( + first: Coordinate | undefined, + second: Coordinate, +): boolean => first?.[0] === second[0] && first[1] === second[1]; + +const toCoordinate = ( + api: OpenLayersApi, + location: MapLocation, + viewProjection: unknown, +): Coordinate => api.proj.transform( + [location.lng, location.lat], + GEOGRAPHIC_PROJECTION, + getCoordinateProjection(api, viewProjection), ); +const toLocation = ( + api: OpenLayersApi, + coordinate: Coordinate, + viewProjection: unknown, +): MapLocation => { + const [lng, lat] = api.proj.toLonLat( + coordinate, + getCoordinateProjection(api, viewProjection), + ); + + return { lat, lng }; +}; + const createTileUrlList = ( url: string, subdomains: MapEngineTileLayerOptions['subdomains'], ): string[] => { const values = Array.isArray(subdomains) ? subdomains : [...(subdomains ?? '')]; - return values.map((value) => url.replace('{s}', value)); + return values.map((value) => url.split('{s}').join(value)); }; class OpenLayersMap implements MapEngineMap { readonly originalMap: MapLike; - private readonly _keyboardEventTarget: Element; + private readonly _container: Element; private readonly _ownsKeyboardTabIndex: boolean; + private _focusEnabled = true; + + private _tabIndex = 0; + + private readonly _zoomControl: ControlLike; + + private _controlsVisible = false; + + private _disabled = false; + + private _ownsDisabledInert = false; + + private readonly _interactionStates = new Map(); + + private _eventHandlers?: { + click: (event: unknown) => void; + moveEnd: (event: unknown) => void; + }; + private _tileLayer?: TileLayerLike; private _disposed = false; @@ -106,27 +196,93 @@ class OpenLayersMap implements MapEngineMap { container: Element, view: MapEngineSetViewOptions = {}, ) { - const rootNode = container.getRootNode(); - const ShadowRootConstructor = container.ownerDocument.defaultView?.ShadowRoot; - const shadowRoot = ShadowRootConstructor && rootNode instanceof ShadowRootConstructor - ? rootNode - : undefined; - - this._keyboardEventTarget = shadowRoot?.host ?? container; - this._ownsKeyboardTabIndex = !this._keyboardEventTarget.hasAttribute('tabindex'); - if (this._ownsKeyboardTabIndex) { - this._keyboardEventTarget.setAttribute('tabindex', '0'); - } + this._container = container; + this._ownsKeyboardTabIndex = !container.hasAttribute('tabindex'); + this._syncKeyboardTabIndex(); this.originalMap = new _api.Map({ controls: _api.control.defaults.defaults({ attribution: true, rotate: false, zoom: false }), - interactions: _api.interaction.defaults.defaults({ onFocusOnly: false }), + interactions: _api.interaction.defaults.defaults({ + altShiftDragRotate: false, + onFocusOnly: false, + pinchRotate: false, + }), + keyboardEventTarget: container, target: container, view: new _api.View({ - center: toCoordinate(_api, view.center ?? { lat: 0, lng: 0 }), + center: toCoordinate( + _api, + view.center ?? { lat: 0, lng: 0 }, + DEFAULT_VIEW_PROJECTION, + ), + projection: DEFAULT_VIEW_PROJECTION, zoom: view.zoom ?? 1, }), }); + this._zoomControl = new _api.control.Zoom(); + } + + attachHandlers(handlers: MapEngineEventHandlers): void { + this._detachHandlers(); + + const click = (event: unknown): void => { + const { coordinate, originalEvent } = event as MapBrowserEventLike; + if (!coordinate) { + return; + } + + const clickEvent: MapEngineClickEvent = { + location: toLocation( + this._api, + coordinate, + this.originalMap.getView().getProjection(), + ), + }; + if (originalEvent) { + clickEvent.event = originalEvent; + } + handlers.click(clickEvent); + }; + const moveEnd = (): void => { + handlers.viewChange(this._getViewState()); + }; + + this._eventHandlers = { click, moveEnd }; + this.originalMap.on('click', click); + this.originalMap.on('moveend', moveEnd); + } + + private _detachHandlers(): void { + if (!this._eventHandlers) { + return; + } + + this.originalMap.un('click', this._eventHandlers.click); + this.originalMap.un('moveend', this._eventHandlers.moveEnd); + this._eventHandlers = undefined; + } + + private _getViewState(): MapEngineViewState { + const view = this.originalMap.getView(); + const center = view.getCenter(); + const zoom = view.getZoom(); + const extent = view.calculateExtent(); + + const result: MapEngineViewState = { + bounds: { + northEast: toLocation(this._api, [extent[2], extent[3]], view.getProjection()), + southWest: toLocation(this._api, [extent[0], extent[1]], view.getProjection()), + }, + }; + + if (center) { + result.center = toLocation(this._api, center, view.getProjection()); + } + if (zoom !== undefined) { + result.zoom = zoom; + } + + return result; } dispose(): void { @@ -135,16 +291,40 @@ class OpenLayersMap implements MapEngineMap { } this._disposed = true; + this._detachHandlers(); + this._removeOwnedInert(); + this.setControls(false); if (this._tileLayer) { this.originalMap.removeLayer(this._tileLayer); this._tileLayer = undefined; } this.originalMap.setTarget(undefined); - if (this._ownsKeyboardTabIndex && this._keyboardEventTarget.getAttribute('tabindex') === '0') { - this._keyboardEventTarget.removeAttribute('tabindex'); + if (this._ownsKeyboardTabIndex + && this._container.getAttribute('tabindex') === String(this._tabIndex)) { + this._container.removeAttribute('tabindex'); } } + fitBounds(bounds: MapEngineBounds): void { + const west = bounds.southWest.lng; + const east = bounds.northEast.lng < west + ? bounds.northEast.lng + 360 + : bounds.northEast.lng; + const geographicExtent: Extent = [ + west, + Math.min(bounds.northEast.lat, bounds.southWest.lat), + east, + Math.max(bounds.northEast.lat, bounds.southWest.lat), + ]; + const view = this.originalMap.getView(); + const extent = this._api.proj.transformExtent( + geographicExtent, + GEOGRAPHIC_PROJECTION, + getCoordinateProjection(this._api, view.getProjection()), + ); + view.fit(extent); + } + replaceTileLayer(options: MapEngineTileLayerOptions): void { const sourceOptions: Options = { maxZoom: options.maxZoom, @@ -168,13 +348,94 @@ class OpenLayersMap implements MapEngineMap { this.originalMap.addLayer(this._tileLayer); } + setControls(visible: boolean): void { + if (visible === this._controlsVisible) { + return; + } + + this._controlsVisible = visible; + if (visible) { + this.originalMap.addControl(this._zoomControl); + } else { + this.originalMap.removeControl(this._zoomControl); + } + } + + setDisabled(disabled: boolean): void { + if (disabled === this._disabled) { + return; + } + + this._disabled = disabled; + const interactions = this.originalMap.getInteractions(); + + if (disabled) { + this._disableKeyboardAccess(); + this._interactionStates.clear(); + interactions.forEach((interaction) => { + this._interactionStates.set(interaction, interaction.getActive()); + interaction.setActive(false); + }); + } else { + this._restoreKeyboardAccess(); + interactions.forEach((interaction) => { + const active = this._interactionStates.get(interaction); + if (active !== undefined) { + interaction.setActive(active); + } + }); + this._interactionStates.clear(); + } + } + + private _disableKeyboardAccess(): void { + if (!this._container.hasAttribute('inert')) { + this._container.setAttribute('inert', ''); + this._ownsDisabledInert = true; + } + this._syncKeyboardTabIndex(); + } + + private _restoreKeyboardAccess(): void { + this._removeOwnedInert(); + this._syncKeyboardTabIndex(); + } + + private _removeOwnedInert(): void { + if (this._ownsDisabledInert) { + this._container.removeAttribute('inert'); + this._ownsDisabledInert = false; + } + } + + setFocus(enabled: boolean, tabIndex: number): void { + this._focusEnabled = enabled; + this._tabIndex = tabIndex; + this._syncKeyboardTabIndex(); + } + + private _syncKeyboardTabIndex(): void { + if (!this._ownsKeyboardTabIndex) { + return; + } + + if (this._focusEnabled && !this._disabled) { + this._container.setAttribute('tabindex', String(this._tabIndex)); + } else { + this._container.removeAttribute('tabindex'); + } + } + setView(options: MapEngineSetViewOptions): void { const view = this.originalMap.getView(); if (options.center) { - view.setCenter(toCoordinate(this._api, options.center)); + const center = toCoordinate(this._api, options.center, view.getProjection()); + if (!areCoordinatesEqual(view.getCenter(), center)) { + view.setCenter(center); + } } - if (options.zoom !== undefined) { + if (options.zoom !== undefined && view.getZoom() !== options.zoom) { view.setZoom(options.zoom); } } 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 6b138f8b8882..2e3862d0ed08 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -15,14 +15,25 @@ import type { import DynamicProvider from './provider.dynamic'; import type { MapEngine, + MapEngineBounds, + MapEngineClickEvent, MapEngineMap, MapEngineTileLayerOptions, + MapEngineViewState, } from './provider.dynamic.osm.engine'; import { getRegisteredMapEngine } from './provider.dynamic.osm.engine'; import { createOpenLayersEngine } from './provider.dynamic.osm.openlayers'; const DEFAULT_MAX_ZOOM = 19; const DEFAULT_SUBDOMAINS = 'abc'; +const LOCATION_EPSILON = 1e-10; + +const areLocationsEqual = ( + first: MapLocation | null, + second: MapLocation, +): boolean => first !== null + && Math.abs(first.lat - second.lat) < LOCATION_EPSILON + && Math.abs(first.lng - second.lng) < LOCATION_EPSILON; class OsmProvider extends DynamicProvider { _engine?: MapEngine; @@ -56,6 +67,12 @@ class OsmProvider extends DynamicProvider { this._engineMap = engineMap; this._map = engineMap.originalMap; + engineMap.setControls(Boolean(this._option('controls'))); + engineMap.setFocus( + Boolean(this._option('focusStateEnabled')), + this._option('tabIndex') ?? 0, + ); + engineMap.setDisabled(Boolean(this._option('disabled'))); return Promise.resolve(); } @@ -103,7 +120,38 @@ class OsmProvider extends DynamicProvider { return this._getLatLng(location) ?? { lat: 0, lng: 0 }; } - _attachHandlers(): void {} + _attachHandlers(): void { + this._engineMap?.attachHandlers({ + click: (event) => this._clickActionHandler(event), + viewChange: (view) => this._viewChangeHandler(view), + }); + } + + _clickActionHandler(event: MapEngineClickEvent): void { + this._fireClickAction(event); + } + + _viewChangeHandler(view: MapEngineViewState): void { + const { bounds, center, zoom } = view; + + if (bounds) { + const currentBounds = this._option('bounds'); + const currentNorthEast = this._getLatLng(currentBounds?.northEast); + const currentSouthWest = this._getLatLng(currentBounds?.southWest); + if (!areLocationsEqual(currentNorthEast, bounds.northEast) + || !areLocationsEqual(currentSouthWest, bounds.southWest)) { + this._option('bounds', bounds); + } + } + + if (center && !areLocationsEqual(this._getLatLng(this._option('center')), center)) { + this._option('center', center); + } + + if (zoom !== undefined && zoom !== this._option('zoom')) { + this._option('zoom', zoom); + } + } updateDimensions(): Promise { this._engineMap?.updateDimensions(); @@ -149,14 +197,36 @@ class OsmProvider extends DynamicProvider { } updateDisabled(): Promise { + this._engineMap?.setDisabled(Boolean(this._option('disabled'))); + + return Promise.resolve(); + } + + updateFocus(): Promise { + this._engineMap?.setFocus( + Boolean(this._option('focusStateEnabled')), + this._option('tabIndex') ?? 0, + ); + return Promise.resolve(); } updateBounds(): Promise { + const bounds = this._option('bounds'); + const northEast = this._getLatLng(bounds?.northEast); + const southWest = this._getLatLng(bounds?.southWest); + + if (northEast && southWest) { + const engineBounds: MapEngineBounds = { northEast, southWest }; + this._engineMap?.fitBounds(engineBounds); + } + return Promise.resolve(); } updateControls(): Promise { + this._engineMap?.setControls(Boolean(this._option('controls'))); + return Promise.resolve(); } diff --git a/packages/devextreme/js/__internal/ui/map/provider.ts b/packages/devextreme/js/__internal/ui/map/provider.ts index 3daf5ce33f85..2a6f6dde734b 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.ts @@ -63,6 +63,10 @@ class Provider { Class.abstract(); } + updateFocus(): Promise { + return Promise.resolve(); + } + updateBounds(): void { Class.abstract(); } diff --git a/packages/devextreme/js/ui/map/openlayers.register.js b/packages/devextreme/js/ui/map/openlayers.register.js index 4e6ec3b8c209..3e66488c1b75 100644 --- a/packages/devextreme/js/ui/map/openlayers.register.js +++ b/packages/devextreme/js/ui/map/openlayers.register.js @@ -1,8 +1,14 @@ import { defaults as defaultControls } from 'ol/control/defaults.js'; +import Zoom from 'ol/control/Zoom.js'; import { defaults as defaultInteractions } from 'ol/interaction/defaults.js'; import TileLayer from 'ol/layer/Tile.js'; import Map from 'ol/Map.js'; -import { fromLonLat } from 'ol/proj.js'; +import { + getUserProjection, + toLonLat, + transform, + transformExtent, +} from 'ol/proj.js'; import ImageTile from 'ol/source/ImageTile.js'; import View from 'ol/View.js'; @@ -13,6 +19,7 @@ setRegisteredMapEngine(createOpenLayersEngine({ Map, View, control: { + Zoom, defaults: { defaults: defaultControls, }, @@ -26,7 +33,10 @@ setRegisteredMapEngine(createOpenLayersEngine({ Tile: TileLayer, }, proj: { - fromLonLat, + getUserProjection, + toLonLat, + transform, + transformExtent, }, source: { ImageTile, From f748602f5228f91597309a04addf33555536cee5 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Thu, 27 Aug 2026 14:13:11 +0300 Subject: [PATCH 02/11] Map: cover OSM viewport and interactions --- .../testing/helpers/forMap/openLayersMock.js | 157 +++++- .../mapParts/osmTests.js | 496 +++++++++++++++++- 2 files changed, 637 insertions(+), 16 deletions(-) diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js index 218bd4c59250..17a86264b898 100644 --- a/packages/devextreme/testing/helpers/forMap/openLayersMock.js +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -2,50 +2,162 @@ (() => { const api = {}; + const GEOGRAPHIC_PROJECTION = 'EPSG:4326'; + const WEB_MERCATOR_PROJECTION = 'EPSG:3857'; + const PROJECTION_SCALE = 1000; + + const transformCoordinate = (coordinate, source, destination) => { + if(source === destination) { + return [...coordinate]; + } + if(source === GEOGRAPHIC_PROJECTION && destination === WEB_MERCATOR_PROJECTION) { + return coordinate.map((value) => value * PROJECTION_SCALE); + } + if(source === WEB_MERCATOR_PROJECTION && destination === GEOGRAPHIC_PROJECTION) { + return coordinate.map((value) => value / PROJECTION_SCALE); + } + + return [...coordinate]; + }; + + class MockCollection { + constructor(items) { + this.items = items; + } + + forEach(callback) { + this.items.forEach(callback); + } + + getArray() { + return this.items; + } + } + + class MockInteraction { + constructor(active) { + this.active = active; + } + + getActive() { + return this.active; + } + + setActive(active) { + this.active = active; + api.interactionStateChanges.push({ interaction: this, active }); + } + } + + class MockZoom { + constructor() { + api.zoomControlCreatedCount += 1; + } + } + class MockView { constructor(options) { this.center = options.center; + this.projection = options.projection || WEB_MERCATOR_PROJECTION; this.zoom = options.zoom; api.viewCenter = options.center; api.viewOptions = options; api.viewZoom = options.zoom; } + + calculateExtent() { + return api.viewExtent; + } + + fit(extent, options) { + api.fittedExtent = extent; + api.fitOptions = options; + } + + getCenter() { + return this.center; + } + + getProjection() { + return this.projection; + } + + getZoom() { + return this.zoom; + } + setCenter(center) { this.center = center; api.viewCenter = center; api.viewCenterSetCount += 1; } + setZoom(zoom) { this.zoom = zoom; api.viewZoom = zoom; + api.viewZoomSetCount += 1; } } + class MockMap { constructor(options) { this.options = options; this.view = options.view; + this.eventHandlers = {}; api.mapCreated = true; api.mapInstance = this; api.mapOptions = options; } + + addControl(control) { + api.addedControls.push(control); + } + addLayer(layer) { api.tileLayer = layer; api.addedTileLayers.push(layer); } + + getInteractions() { + return this.options.interactions; + } + getView() { return this.view; } + + on(type, listener) { + this.eventHandlers[type] = this.eventHandlers[type] || []; + this.eventHandlers[type].push(listener); + } + + removeControl(control) { + api.removedControls.push(control); + } + removeLayer(layer) { api.removedLayers.push(layer); } + setTarget(target) { this.target = target; api.mapTarget = target; } + updateSize() { api.mapResized = true; } + + un(type, listener) { + const handlers = this.eventHandlers[type] || []; + this.eventHandlers[type] = handlers.filter((handler) => handler !== listener); + } + + trigger(type, event) { + (this.eventHandlers[type] || []).slice().forEach((handler) => handler(event || {})); + } } + class MockImageTile { constructor(options) { if(api.throwOnTileSource) { @@ -55,24 +167,28 @@ api.tileSourceOptions = options; } } + class MockTileLayer { constructor(options) { this.source = options.source; api.tileLayerOptions = options; } + setSource(source) { this.source = source; api.tileSourceChanges.push(source); } } + Object.assign(api, { Map: MockMap, View: MockView, control: { + Zoom: MockZoom, defaults: { defaults(options) { api.controlOptions = options; - return []; + return new MockCollection([]); } } }, @@ -80,7 +196,8 @@ defaults: { defaults(options) { api.interactionOptions = options; - return []; + api.interactions = [new MockInteraction(true), new MockInteraction(false)]; + return new MockCollection(api.interactions); } } }, @@ -88,9 +205,39 @@ Tile: MockTileLayer }, proj: { - fromLonLat(coordinate) { - api.projectedCoordinates.push([...coordinate]); - return coordinate; + getUserProjection() { + return api.userProjection; + }, + toLonLat(coordinate, projection) { + const result = transformCoordinate(coordinate, projection, GEOGRAPHIC_PROJECTION); + if(result[0] < -180 || result[0] > 180) { + result[0] = ((result[0] + 180) % 360 + 360) % 360 - 180; + } + + return result; + }, + transform(coordinate, source, destination) { + api.transformedCoordinates.push({ + coordinate: [...coordinate], + destination, + source + }); + if(source === GEOGRAPHIC_PROJECTION) { + api.projectedCoordinates.push([...coordinate]); + } + + return transformCoordinate(coordinate, source, destination); + }, + transformExtent(extent, source, destination) { + api.transformedExtents.push({ + destination, + extent: [...extent], + source + }); + const southWest = transformCoordinate([extent[0], extent[1]], source, destination); + const northEast = transformCoordinate([extent[2], extent[3]], source, destination); + + return [southWest[0], southWest[1], northEast[0], northEast[1]]; } }, source: { 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 3845170a299e..ec8a0c8bc015 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -10,25 +10,37 @@ import 'ui/map'; let openLayersMock; const resetOpenLayersMock = () => { Object.assign(openLayersMock, { + addedControls: [], addedTileLayers: [], controlOptions: null, + fitOptions: null, + fittedExtent: null, interactionOptions: null, + interactions: [], + interactionStateChanges: [], mapCreated: false, mapInstance: null, mapOptions: null, mapResized: false, mapTarget: null, projectedCoordinates: [], + removedControls: [], removedLayers: [], throwOnTileSource: false, tileLayer: null, tileLayerOptions: null, tileSourceChanges: [], tileSourceOptions: null, + transformedCoordinates: [], + transformedExtents: [], + userProjection: null, viewCenter: null, viewCenterSetCount: 0, + viewExtent: [-74100, 40600, -73800, 40900], viewOptions: null, - viewZoom: null + viewZoom: null, + viewZoomSetCount: 0, + zoomControlCreatedCount: 0 }); }; const moduleConfig = { @@ -67,6 +79,8 @@ const createProvider = () => new OsmProvider({ providerConfig: {} }) }, null); +const getOpenLayersKeyboardTarget = () => openLayersMock.mapOptions.keyboardEventTarget; +const getOpenLayersMapTarget = () => openLayersMock.mapOptions.target; QUnit.module('OSM: map loading', moduleConfig, () => { QUnit.test('registered OpenLayers engine takes priority over window.ol', function(assert) { const done = assert.async(); @@ -123,12 +137,20 @@ QUnit.module('OSM: map loading', moduleConfig, () => { const container = document.createElement('div'); const engineMap = engine.createMap(container); assert.strictEqual(container.getAttribute('tabindex'), '0', 'map target is focusable'); - assert.deepEqual(openLayersMock.interactionOptions, { - onFocusOnly: false - }, 'pointer interactions do not require focus'); + assert.strictEqual(openLayersMock.mapOptions.keyboardEventTarget, container, 'map target receives keyboard events'); engineMap.dispose(); assert.strictEqual(container.getAttribute('tabindex'), null, 'added tabindex is removed on dispose'); }); + QUnit.test('OpenLayers interactions are configured for dxMap', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + assert.deepEqual(openLayersMock.interactionOptions, { + altShiftDragRotate: false, + onFocusOnly: false, + pinchRotate: false + }, 'pointer interactions work without focus and cannot rotate the map'); + engineMap.dispose(); + }); QUnit.test('OpenLayers controls are configured for the tiles stage', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const engineMap = engine.createMap(document.createElement('div')); @@ -137,9 +159,10 @@ QUnit.module('OSM: map loading', moduleConfig, () => { rotate: false, zoom: false }, 'only the attribution control remains enabled'); + assert.strictEqual(openLayersMock.addedControls.length, 0, 'zoom control is not added by default'); engineMap.dispose(); }); - QUnit.test('OpenLayers Shadow DOM host is keyboard focusable', function(assert) { + QUnit.test('OpenLayers map target is the keyboard target in Shadow DOM', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const host = document.createElement('div'); const shadowRoot = host.attachShadow({ @@ -148,10 +171,11 @@ QUnit.module('OSM: map loading', moduleConfig, () => { const container = document.createElement('div'); shadowRoot.appendChild(container); const engineMap = engine.createMap(container); - assert.strictEqual(host.getAttribute('tabindex'), '0', 'Shadow DOM host is focusable'); - assert.strictEqual(container.getAttribute('tabindex'), null, 'nested map target does not add a second tab stop'); + assert.strictEqual(host.getAttribute('tabindex'), null, 'Shadow DOM host is unchanged'); + assert.strictEqual(container.getAttribute('tabindex'), '0', 'map target is focusable'); + assert.strictEqual(openLayersMock.mapOptions.keyboardEventTarget, container, 'map target receives keyboard events'); engineMap.dispose(); - assert.strictEqual(host.getAttribute('tabindex'), null, 'added host tabindex is removed on dispose'); + assert.strictEqual(container.getAttribute('tabindex'), null, 'added map target tabindex is removed on dispose'); }); QUnit.test('existing tabindex is preserved on dispose', function(assert) { const engine = createOpenLayersEngine(openLayersMock); @@ -161,6 +185,43 @@ QUnit.module('OSM: map loading', moduleConfig, () => { engineMap.dispose(); assert.strictEqual(container.getAttribute('tabindex'), '-1', 'client tabindex is preserved'); }); + QUnit.test('existing inert attribute is preserved after disabled is toggled', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + container.setAttribute('inert', ''); + const engineMap = engine.createMap(container); + engineMap.setDisabled(true); + engineMap.setDisabled(false); + assert.ok(container.hasAttribute('inert'), 'client inert attribute is preserved'); + engineMap.dispose(); + }); + QUnit.test('owned inert attribute is removed on dispose', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + const engineMap = engine.createMap(container); + engineMap.setDisabled(true); + assert.ok(container.hasAttribute('inert'), 'map target is inert while disabled'); + engineMap.dispose(); + assert.notOk(container.hasAttribute('inert'), 'owned inert attribute is removed'); + }); + QUnit.test('disabled map does not make its Shadow DOM host inert', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const container = document.createElement('div'); + const sibling = document.createElement('button'); + shadowRoot.append(container, sibling); + const engineMap = engine.createMap(container); + engineMap.setDisabled(true); + assert.ok(container.hasAttribute('inert'), 'map container is inert'); + assert.notOk(host.hasAttribute('inert'), 'Shadow DOM host remains interactive'); + assert.notOk(sibling.hasAttribute('inert'), 'sibling remains interactive'); + assert.strictEqual(container.getAttribute('tabindex'), null, 'owned map target tabindex is removed'); + engineMap.setDisabled(false); + assert.notOk(container.hasAttribute('inert'), 'map container becomes interactive'); + assert.strictEqual(container.getAttribute('tabindex'), '0', 'owned map target tabindex is restored'); + engineMap.dispose(); + }); QUnit.test('tabindex changed after initialization is preserved on dispose', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const container = document.createElement('div'); @@ -244,6 +305,22 @@ QUnit.module('OSM: map loading', moduleConfig, () => { done(); }); }); + ['getUserProjection', 'toLonLat', 'transform', 'transformExtent'].forEach(apiName => { + QUnit.test(`load rejects with E1069 when the OpenLayers ${apiName} API is missing`, function(assert) { + const done = assert.async(); + const provider = createProvider(); + const projectionApi = Object.assign({}, openLayersMock.proj); + delete projectionApi[apiName]; + window.ol = Object.assign({}, openLayersMock, { proj: projectionApi }); + provider._loadImpl().then(() => { + assert.ok(false, 'load should reject'); + done(); + }, error => { + assert.strictEqual(error.message, errors.Error('E1069').message, 'E1069 is returned'); + done(); + }); + }); + }); QUnit.test('dispose detaches the OpenLayers map and removes its tile layer', function(assert) { const done = assert.async(); const map = $('#map').dxMap({ @@ -361,13 +438,13 @@ QUnit.module('OSM: tile server', moduleConfig, () => { provider: 'osm', providerConfig: { tileServer: { - url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png', + url: 'https://{s}.tiles.example.com/{z}/{x}/{y}.png?mirror={s}', attribution: 'Example attribution', subdomains: 'ab' } }, onReady: () => { - assert.deepEqual(openLayersMock.tileSourceOptions.url, ['https://a.tiles.example.com/{z}/{x}/{y}.png', 'https://b.tiles.example.com/{z}/{x}/{y}.png'], 'subdomains are expanded'); + assert.deepEqual(openLayersMock.tileSourceOptions.url, ['https://a.tiles.example.com/{z}/{x}/{y}.png?mirror=a', 'https://b.tiles.example.com/{z}/{x}/{y}.png?mirror=b'], 'all subdomain placeholders are expanded'); done(); } }); @@ -630,8 +707,10 @@ QUnit.module('OSM: initial view', moduleConfig, () => { const lastProjectedCoordinate = openLayersMock.projectedCoordinates[openLayersMock.projectedCoordinates.length - 1]; assert.ok(openLayersMock.projectedCoordinates.length > 0, 'projection API is called'); assert.deepEqual(lastProjectedCoordinate, [-73.98, 40.74], 'longitude and latitude are passed to the projection API'); - assert.deepEqual(openLayersMock.viewCenter, [-73.98, 40.74], 'projected center is applied'); + assert.deepEqual(openLayersMock.viewCenter, [-73980, 40740], 'projected center is applied'); assert.strictEqual(openLayersMock.viewZoom, 12.5, 'fractional zoom is applied'); + assert.strictEqual(openLayersMock.viewCenterSetCount, 0, 'initial center is not applied twice'); + assert.strictEqual(openLayersMock.viewZoomSetCount, 0, 'initial zoom is not applied twice'); done(); } }); @@ -697,3 +776,398 @@ QUnit.module('OSM: initial view', moduleConfig, () => { }); }); }); +QUnit.module('OSM: viewport and interactions', moduleConfig, () => { + QUnit.test('focus options are applied to the OpenLayers keyboard target', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + focusStateEnabled: false, + tabIndex: 5, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + const target = getOpenLayersKeyboardTarget(); + assert.strictEqual(target.getAttribute('tabindex'), null, 'focus is disabled on initialization'); + map.option('focusStateEnabled', true); + map._lastAsyncAction.then(() => { + assert.strictEqual(target.getAttribute('tabindex'), '5', 'configured tabIndex is applied'); + map.option('tabIndex', -1); + map._lastAsyncAction.then(() => { + assert.strictEqual(target.getAttribute('tabindex'), '-1', 'runtime tabIndex is applied'); + map.option('focusStateEnabled', false); + map._lastAsyncAction.then(() => { + assert.strictEqual(target.getAttribute('tabindex'), null, 'runtime focus disabling is applied'); + done(); + }); + }); + }); + } + }).dxMap('instance'); + }); + QUnit.test('OpenLayers view changes update center, fractional zoom, and bounds', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + const view = openLayersMock.mapInstance.getView(); + view.setCenter([-73980, 40740]); + view.setZoom(12.5); + const centerSetCount = openLayersMock.viewCenterSetCount; + const zoomSetCount = openLayersMock.viewZoomSetCount; + openLayersMock.viewExtent = [-74100, 40600, -73800, 40900]; + openLayersMock.mapInstance.trigger('moveend'); + assert.deepEqual(map.option('center'), { + lat: 40.74, + lng: -73.98 + }, 'center is synchronized'); + assert.strictEqual(map.option('zoom'), 12.5, 'fractional zoom is synchronized'); + assert.deepEqual(map.option('bounds'), { + northEast: { + lat: 40.9, + lng: -73.8 + }, + southWest: { + lat: 40.6, + lng: -74.1 + } + }, 'bounds are synchronized'); + assert.strictEqual(openLayersMock.viewCenterSetCount, centerSetCount, 'center is not written back to OpenLayers'); + assert.strictEqual(openLayersMock.viewZoomSetCount, zoomSetCount, 'zoom is not written back to OpenLayers'); + done(); + } + }).dxMap('instance'); + }); + QUnit.test('OpenLayers click fires onClick with location and original event', function(assert) { + const done = assert.async(); + const originalEvent = new PointerEvent('click'); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onClick: event => { + assert.strictEqual(event.component, map, 'component is passed'); + assert.deepEqual(event.location, { + lat: 40.74, + lng: -73.98 + }, 'location is normalized'); + assert.strictEqual(event.event, originalEvent, 'original event is passed'); + done(); + }, + onReady: () => { + openLayersMock.mapInstance.trigger('click', { + coordinate: [-73980, 40740], + originalEvent + }); + } + }).dxMap('instance'); + }); + QUnit.test('OpenLayers click without a coordinate is ignored', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const click = sinon.spy(); + engineMap.attachHandlers({ click, viewChange: sinon.spy() }); + openLayersMock.mapInstance.trigger('click', { + originalEvent: new PointerEvent('click') + }); + assert.ok(click.notCalled, 'click handler is not called without a location'); + engineMap.dispose(); + }); + QUnit.test('OpenLayers user projection is honored for the initial view, synchronization, and bounds', function(assert) { + const done = assert.async(); + openLayersMock.userProjection = 'EPSG:4326'; + const map = $('#map').dxMap({ + provider: 'osm', + center: { + lat: 40.74, + lng: -73.98 + }, + zoom: 12.5, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + const view = openLayersMock.mapInstance.getView(); + assert.deepEqual(openLayersMock.viewCenter, [-73.98, 40.74], 'initial center uses the user projection'); + view.setCenter([-73.97, 40.75]); + openLayersMock.viewExtent = [-74.1, 40.6, -73.8, 40.9]; + openLayersMock.mapInstance.trigger('moveend'); + assert.deepEqual(map.option('center'), { + lat: 40.75, + lng: -73.97 + }, 'center is synchronized from the user projection'); + assert.deepEqual(map.option('bounds'), { + northEast: { + lat: 40.9, + lng: -73.8 + }, + southWest: { + lat: 40.6, + lng: -74.1 + } + }, 'bounds are synchronized from the user projection'); + map.option('bounds', { + northEast: { + lat: 40.8, + lng: -73.9 + }, + southWest: { + lat: 40.7, + lng: -74 + } + }); + map._lastAsyncAction.then(() => { + assert.deepEqual(openLayersMock.fittedExtent, [-74, 40.7, -73.9, 40.8], 'bounds are fitted in the user projection'); + done(); + }); + } + }).dxMap('instance'); + }); + QUnit.test('bounds crossing the antimeridian use the shorter wrapped extent', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + bounds: { + northEast: { + lat: 10, + lng: -170 + }, + southWest: { + lat: -10, + lng: 170 + } + }, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [170000, -10000, 190000, 10000], 'the wrapped 20 degree extent is fitted'); + done(); + } + }); + }); + QUnit.test('OpenLayers wrapped view coordinates are normalized on synchronization', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + openLayersMock.mapInstance.getView().setCenter([190000, 0]); + openLayersMock.viewExtent = [170000, -10000, 190000, 10000]; + openLayersMock.mapInstance.trigger('moveend'); + assert.deepEqual(map.option('center'), { + lat: 0, + lng: -170 + }, 'center longitude is normalized'); + assert.deepEqual(map.option('bounds'), { + northEast: { + lat: 10, + lng: -170 + }, + southWest: { + lat: -10, + lng: 170 + } + }, 'bounds preserve the antimeridian crossing'); + done(); + } + }).dxMap('instance'); + }); + QUnit.test('controls option toggles the OpenLayers zoom control', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + controls: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is added on initialization'); + map.option('controls', false); + map._lastAsyncAction.then(() => { + assert.strictEqual(openLayersMock.removedControls.length, 1, 'zoom control is removed'); + map.option('controls', true); + map._lastAsyncAction.then(() => { + assert.strictEqual(openLayersMock.addedControls.length, 2, 'zoom control is added again'); + done(); + }); + }); + } + }).dxMap('instance'); + }); + QUnit.test('disabled option restores the previous interaction states', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + controls: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is present'); + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [true, false], 'initial states are preserved'); + assert.notOk(getOpenLayersMapTarget().hasAttribute('inert'), 'map target is keyboard accessible'); + map.option('disabled', true); + map._lastAsyncAction.then(() => { + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [false, false], 'all interactions are disabled'); + assert.ok(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls are removed from keyboard navigation'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), null, 'owned keyboard target tabindex is removed'); + map.option('disabled', false); + map._lastAsyncAction.then(() => { + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [true, false], 'previous states are restored'); + assert.notOk(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls return to keyboard navigation'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), '0', 'owned keyboard target tabindex is restored'); + done(); + }); + }); + } + }).dxMap('instance'); + }); + QUnit.test('disabled option is applied on initialization', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + controls: true, + disabled: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + } + }).dxMap('instance'); + map._lastAsyncAction.then(() => { + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is present'); + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [false, false], 'all interactions are disabled'); + assert.ok(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls are removed from keyboard navigation'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), null, 'owned keyboard target tabindex is removed'); + done(); + }); + }); + QUnit.test('bounds option fits the OpenLayers view on initialization and at runtime', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + bounds: { + northEast: { + lat: 40.8, + lng: -73.9 + }, + southWest: { + lat: 40.7, + lng: -74 + } + }, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.deepEqual(openLayersMock.fittedExtent, [-74000, 40700, -73900, 40800], 'initial bounds are fitted'); + assert.strictEqual(openLayersMock.fitOptions, undefined, 'OpenLayers selects the viewport size'); + map.option('bounds', { + northEast: [40.9, -73.8], + southWest: [40.6, -74.1] + }); + map._lastAsyncAction.then(() => { + assert.deepEqual(openLayersMock.fittedExtent, [-74100, 40600, -73800, 40900], 'runtime bounds are fitted'); + done(); + }); + } + }).dxMap('instance'); + }); + QUnit.test('incomplete bounds do not change the OpenLayers view', function(assert) { + const done = assert.async(); + const map = $('#map').dxMap({ + provider: 'osm', + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + map.option('bounds', { + northEast: { + lat: 40.8, + lng: -73.9 + }, + southWest: null + }); + map._lastAsyncAction.then(() => { + assert.strictEqual(openLayersMock.fittedExtent, null, 'view is not fitted without both bounds'); + done(); + }); + } + }).dxMap('instance'); + }); + QUnit.test('RTL mode preserves viewport and keyboard behavior', function(assert) { + const done = assert.async(); + $('#map').dxMap({ + provider: 'osm', + center: { + lat: 40.74, + lng: -73.98 + }, + controls: true, + rtlEnabled: true, + providerConfig: { + tileServer: { + url: 'https://tiles.example.com/{z}/{x}/{y}.png', + attribution: 'Example attribution' + } + }, + onReady: () => { + assert.ok($('#map').hasClass('dx-rtl'), 'RTL mode is applied to the widget'); + assert.deepEqual(openLayersMock.viewCenter, [-73980, 40740], 'center coordinates are not mirrored'); + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control remains available'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), '0', 'map remains keyboard focusable'); + done(); + } + }); + }); + QUnit.test('dispose detaches OpenLayers event handlers', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const engineMap = engine.createMap(document.createElement('div')); + const click = sinon.spy(); + const viewChange = sinon.spy(); + engineMap.attachHandlers({ click, viewChange }); + engineMap.dispose(); + openLayersMock.mapInstance.trigger('click', { coordinate: [-73980, 40740] }); + openLayersMock.mapInstance.trigger('moveend'); + assert.ok(click.notCalled, 'click handler is detached'); + assert.ok(viewChange.notCalled, 'view change handler is detached'); + }); +}); From b99e151b5174f96ef27f1fb0c6dd454ac4d95f44 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Thu, 27 Aug 2026 14:13:39 +0300 Subject: [PATCH 03/11] Map: extend the OSM viewport showcase --- .../stories/map/OSMMap.stories.tsx | 80 ++++++++++++++++--- 1 file changed, 71 insertions(+), 9 deletions(-) diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index e96a51a62e69..ffe3600dcb2e 100644 --- a/apps/react-storybook/stories/map/OSMMap.stories.tsx +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { useArgs } from 'storybook/preview-api'; import Attribution from 'ol/control/Attribution.js'; import type OpenLayersMap from 'ol/Map.js'; @@ -7,7 +8,11 @@ import { fromLonLat, transformExtent } from 'ol/proj.js'; import View from 'ol/View.js'; import React from 'react'; import Map from 'devextreme-react/map'; -import type { ReadyEvent } from 'devextreme/ui/map'; +import type { + MapLocation, + MapType, + ReadyEvent, +} from 'devextreme/ui/map'; import 'devextreme/ui/map/openlayers'; const CENTER = { lat: 40.7484, lng: -73.9857 }; @@ -17,14 +22,25 @@ const TILE_SERVER = { attribution: '© OpenStreetMap contributors', maxZoom: 19, }; -const PROVIDER_CONFIG = { tileServer: TILE_SERVER }; +const PROVIDER_CONFIG = { tileServer: () => TILE_SERVER }; interface OsmStoryArgs { + center: MapLocation; + controls: boolean; + disabled: boolean; + focusStateEnabled: boolean; + rtlEnabled: boolean; + type: MapType; zoom: number; } -const configureMap = ( +interface OsmMapStoryProps extends OsmStoryArgs { + updateArgs: (args: Partial) => void; +} + +const configureOpenLayersMap = ( { originalMap }: ReadyEvent, + center: MapLocation, zoom: number, ): void => { const map = originalMap as OpenLayersMap; @@ -34,36 +50,76 @@ const configureMap = ( attribution?.setCollapsed(false); attribution?.setCollapsible(false); map.setView(new View({ - center: fromLonLat([CENTER.lng, CENTER.lat]), + center: fromLonLat([center.lng, center.lat]), extent: transformExtent(EXTENT, 'EPSG:4326', 'EPSG:3857'), maxZoom: 16, minZoom: 14, - showFullExtent: false, smoothExtentConstraint: false, zoom, })); }; -const OsmMapStory = ({ zoom }: OsmStoryArgs): React.ReactElement => ( +const OsmMapStory = ({ + center, + controls, + disabled, + focusStateEnabled, + rtlEnabled, + type, + updateArgs, + zoom, +}: OsmMapStoryProps): React.ReactElement => ( configureMap(event, zoom)} + onCenterChange={(value) => updateArgs({ center: value as MapLocation })} + onReady={(event) => configureOpenLayersMap(event, center, zoom)} + onZoomChange={(value) => updateArgs({ zoom: value })} /> ); const meta: Meta = { title: 'Components/Map/OSM Provider', - component: OsmMapStory, tags: ['!test'], + render: function Render() { + const [args, updateArgs] = useArgs(); + + return ; + }, parameters: { layout: 'fullscreen', }, argTypes: { + center: { + control: 'object', + }, + controls: { + control: 'boolean', + }, + disabled: { + control: 'boolean', + }, + focusStateEnabled: { + control: 'boolean', + }, + rtlEnabled: { + control: 'boolean', + }, + type: { + control: 'select', + options: ['roadmap', 'satellite', 'hybrid'], + description: 'The public OSM tile server provides one style, so this control exercises ' + + 'type changes without changing the map appearance.', + }, zoom: { control: { type: 'number', @@ -81,6 +137,12 @@ type Story = StoryObj; export const Default: Story = { args: { + center: CENTER, + controls: true, + disabled: false, + focusStateEnabled: true, + rtlEnabled: false, + type: 'roadmap', zoom: 15, }, }; From e7e26bf2e3e9a6afa9c707af1749e7152af67935 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Thu, 27 Aug 2026 15:01:26 +0300 Subject: [PATCH 04/11] Map: preserve externally updated tab index --- .../ui/map/provider.dynamic.osm.openlayers.ts | 20 +++++++++++++------ .../mapParts/osmTests.js | 19 ++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) 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 9e2d02b2536c..916b7a9c3cc6 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 @@ -166,7 +166,7 @@ class OpenLayersMap implements MapEngineMap { private readonly _container: Element; - private readonly _ownsKeyboardTabIndex: boolean; + private _ownedKeyboardTabIndex: string | null | undefined; private _focusEnabled = true; @@ -197,7 +197,7 @@ class OpenLayersMap implements MapEngineMap { view: MapEngineSetViewOptions = {}, ) { this._container = container; - this._ownsKeyboardTabIndex = !container.hasAttribute('tabindex'); + this._ownedKeyboardTabIndex = container.hasAttribute('tabindex') ? undefined : null; this._syncKeyboardTabIndex(); this.originalMap = new _api.Map({ @@ -299,8 +299,8 @@ class OpenLayersMap implements MapEngineMap { this._tileLayer = undefined; } this.originalMap.setTarget(undefined); - if (this._ownsKeyboardTabIndex - && this._container.getAttribute('tabindex') === String(this._tabIndex)) { + if (this._ownedKeyboardTabIndex !== undefined + && this._container.getAttribute('tabindex') === this._ownedKeyboardTabIndex) { this._container.removeAttribute('tabindex'); } } @@ -415,14 +415,22 @@ class OpenLayersMap implements MapEngineMap { } private _syncKeyboardTabIndex(): void { - if (!this._ownsKeyboardTabIndex) { + if (this._ownedKeyboardTabIndex === undefined) { + return; + } + + if (this._container.getAttribute('tabindex') !== this._ownedKeyboardTabIndex) { + this._ownedKeyboardTabIndex = undefined; return; } if (this._focusEnabled && !this._disabled) { - this._container.setAttribute('tabindex', String(this._tabIndex)); + const tabIndex = String(this._tabIndex); + this._container.setAttribute('tabindex', tabIndex); + this._ownedKeyboardTabIndex = tabIndex; } else { this._container.removeAttribute('tabindex'); + this._ownedKeyboardTabIndex = null; } } 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 ec8a0c8bc015..32f1ed741a01 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -230,6 +230,25 @@ QUnit.module('OSM: map loading', moduleConfig, () => { engineMap.dispose(); assert.strictEqual(container.getAttribute('tabindex'), '-1', 'updated client tabindex is preserved'); }); + QUnit.test('tabindex changed after initialization is preserved on runtime focus updates', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + const engineMap = engine.createMap(container); + container.setAttribute('tabindex', '-1'); + engineMap.setFocus(true, 5); + assert.strictEqual(container.getAttribute('tabindex'), '-1', 'updated client tabindex is preserved'); + engineMap.dispose(); + }); + QUnit.test('tabindex changed after initialization is preserved when disabled is toggled', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + const engineMap = engine.createMap(container); + container.setAttribute('tabindex', '-1'); + engineMap.setDisabled(true); + engineMap.setDisabled(false); + assert.strictEqual(container.getAttribute('tabindex'), '-1', 'updated client tabindex is preserved'); + engineMap.dispose(); + }); QUnit.test('engine map can be disposed more than once', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const engineMap = engine.createMap(document.createElement('div')); From 04426ce8e250b46353f284a633b974be35c61d2f Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 28 Aug 2026 13:09:36 +0300 Subject: [PATCH 05/11] Map: name the OSM subdomain placeholder --- .../js/__internal/ui/map/provider.dynamic.osm.engine.ts | 2 ++ .../__internal/ui/map/provider.dynamic.osm.openlayers.ts | 5 +++-- .../js/__internal/ui/map/provider.dynamic.osm.ts | 7 +++++-- 3 files changed, 10 insertions(+), 4 deletions(-) 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 b438bb68018d..eb011504792e 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 @@ -1,5 +1,7 @@ import type { MapLocation } from '@js/ui/map'; +export const SUBDOMAIN_PLACEHOLDER = '{s}'; + export interface MapEngineTileLayerOptions { attribution?: string; maxZoom: number; 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 916b7a9c3cc6..772766ff4804 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 @@ -10,6 +10,7 @@ import type { MapEngineTileLayerOptions, MapEngineViewState, } from './provider.dynamic.osm.engine'; +import { SUBDOMAIN_PLACEHOLDER } from './provider.dynamic.osm.engine'; type Options = Record; type Coordinate = [number, number]; @@ -158,7 +159,7 @@ const createTileUrlList = ( ): string[] => { const values = Array.isArray(subdomains) ? subdomains : [...(subdomains ?? '')]; - return values.map((value) => url.split('{s}').join(value)); + return values.map((value) => url.split(SUBDOMAIN_PLACEHOLDER).join(value)); }; class OpenLayersMap implements MapEngineMap { @@ -328,7 +329,7 @@ class OpenLayersMap implements MapEngineMap { replaceTileLayer(options: MapEngineTileLayerOptions): void { const sourceOptions: Options = { maxZoom: options.maxZoom, - url: options.url.includes('{s}') + url: options.url.includes(SUBDOMAIN_PLACEHOLDER) ? createTileUrlList(options.url, options.subdomains) : options.url, }; 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 2e3862d0ed08..16752dd3fee6 100644 --- a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.ts @@ -21,7 +21,10 @@ import type { MapEngineTileLayerOptions, MapEngineViewState, } from './provider.dynamic.osm.engine'; -import { getRegisteredMapEngine } from './provider.dynamic.osm.engine'; +import { + getRegisteredMapEngine, + SUBDOMAIN_PLACEHOLDER, +} from './provider.dynamic.osm.engine'; import { createOpenLayersEngine } from './provider.dynamic.osm.openlayers'; const DEFAULT_MAX_ZOOM = 19; @@ -109,7 +112,7 @@ class OsmProvider extends DynamicProvider { result.attribution = config.attribution; } - if (config.url.includes('{s}')) { + if (config.url.includes(SUBDOMAIN_PLACEHOLDER)) { result.subdomains = config.subdomains?.length ? config.subdomains : DEFAULT_SUBDOMAINS; } From 702d8e410a51a3b073837203453c80ab2b287247 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 28 Aug 2026 13:15:24 +0300 Subject: [PATCH 06/11] Map: extract OpenLayers adapter utilities --- .../ui/map/provider.dynamic.osm.openlayers.ts | 167 +++--------------- ...vider.dynamic.osm.openlayers.utils.test.ts | 106 +++++++++++ .../provider.dynamic.osm.openlayers.utils.ts | 149 ++++++++++++++++ 3 files changed, 275 insertions(+), 147 deletions(-) create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts create mode 100644 packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts 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 772766ff4804..41b158361a52 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,3 @@ -import type { MapLocation } from '@js/ui/map'; - import type { MapEngine, MapEngineBounds, @@ -11,157 +9,32 @@ import type { MapEngineViewState, } from './provider.dynamic.osm.engine'; import { SUBDOMAIN_PLACEHOLDER } from './provider.dynamic.osm.engine'; - -type Options = Record; -type Coordinate = [number, number]; -type Extent = [number, number, number, number]; - -const GEOGRAPHIC_PROJECTION = 'EPSG:4326'; -const DEFAULT_VIEW_PROJECTION = 'EPSG:3857'; - -type ControlLike = object; - -interface InteractionLike { - getActive: () => boolean; - setActive: (active: boolean) => void; -} - -interface CollectionLike { - forEach: (callback: (item: T) => void) => void; -} - -interface ViewLike { - calculateExtent: () => Extent; - fit: (extent: Extent) => void; - getCenter: () => Coordinate | undefined; - getProjection: () => unknown; - getZoom: () => number | undefined; - setCenter: (center: Coordinate) => void; - setZoom: (zoom: number) => void; -} - -interface TileLayerLike { - setSource: (source: unknown) => void; -} - -interface MapLike { - addControl: (control: ControlLike) => void; - addLayer: (layer: unknown) => void; - getInteractions: () => CollectionLike; - getView: () => ViewLike; - on: (type: string, listener: (event: unknown) => void) => void; - removeControl: (control: ControlLike) => void; - removeLayer: (layer: unknown) => void; - setTarget: (target?: Element) => void; - un: (type: string, listener: (event: unknown) => void) => void; - updateSize: () => void; -} +import type { + ControlLike, + Coordinate, + Extent, + InteractionLike, + MapLike, + OpenLayersApi, + Options, + TileLayerLike, +} from './provider.dynamic.osm.openlayers.utils'; +import { + areCoordinatesEqual, + createTileUrlList, + DEFAULT_VIEW_PROJECTION, + GEOGRAPHIC_PROJECTION, + getCoordinateProjection, + isOpenLayersApi, + toCoordinate, + toLocation, +} from './provider.dynamic.osm.openlayers.utils'; interface MapBrowserEventLike { coordinate?: Coordinate; originalEvent?: Event; } -interface OpenLayersApi { - Map: new (options: Options) => MapLike; - View: new (options: Options) => ViewLike; - control: { - Zoom: new () => ControlLike; - defaults: { - defaults: (options?: Options) => unknown; - }; - }; - interaction: { - defaults: { - defaults: (options?: Options) => unknown; - }; - }; - layer: { - Tile: new (options: Options) => TileLayerLike; - }; - proj: { - getUserProjection: () => unknown | null; - toLonLat: (coordinate: Coordinate, projection?: unknown) => Coordinate; - transform: (coordinate: Coordinate, source: unknown, destination: unknown) => Coordinate; - transformExtent: (extent: Extent, source: unknown, destination: unknown) => Extent; - }; - source: { - ImageTile: new (options: Options) => unknown; - }; -} - -const isRecord = (value: unknown): value is Record => ( - Boolean(value) && (typeof value === 'object' || typeof value === 'function') -); - -const hasFunction = (value: unknown, property: string): boolean => ( - isRecord(value) && typeof value[property] === 'function' -); - -const isOpenLayersApi = (api: unknown): api is OpenLayersApi => { - if (!isRecord(api)) { - return false; - } - - return typeof api.Map === 'function' - && typeof api.View === 'function' - && isRecord(api.control) - && hasFunction(api.control, 'Zoom') - && isRecord(api.control.defaults) - && hasFunction(api.control.defaults, 'defaults') - && isRecord(api.interaction) - && isRecord(api.interaction.defaults) - && hasFunction(api.interaction.defaults, 'defaults') - && hasFunction(api.layer, 'Tile') - && hasFunction(api.proj, 'getUserProjection') - && hasFunction(api.proj, 'toLonLat') - && hasFunction(api.proj, 'transform') - && hasFunction(api.proj, 'transformExtent') - && hasFunction(api.source, 'ImageTile'); -}; - -const getCoordinateProjection = ( - api: OpenLayersApi, - viewProjection: unknown, -): unknown => api.proj.getUserProjection() ?? viewProjection; - -const areCoordinatesEqual = ( - first: Coordinate | undefined, - second: Coordinate, -): boolean => first?.[0] === second[0] && first[1] === second[1]; - -const toCoordinate = ( - api: OpenLayersApi, - location: MapLocation, - viewProjection: unknown, -): Coordinate => api.proj.transform( - [location.lng, location.lat], - GEOGRAPHIC_PROJECTION, - getCoordinateProjection(api, viewProjection), -); - -const toLocation = ( - api: OpenLayersApi, - coordinate: Coordinate, - viewProjection: unknown, -): MapLocation => { - const [lng, lat] = api.proj.toLonLat( - coordinate, - getCoordinateProjection(api, viewProjection), - ); - - return { lat, lng }; -}; - -const createTileUrlList = ( - url: string, - subdomains: MapEngineTileLayerOptions['subdomains'], -): string[] => { - const values = Array.isArray(subdomains) ? subdomains : [...(subdomains ?? '')]; - - return values.map((value) => url.split(SUBDOMAIN_PLACEHOLDER).join(value)); -}; - class OpenLayersMap implements MapEngineMap { readonly originalMap: MapLike; diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts new file mode 100644 index 000000000000..d817293727c6 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.test.ts @@ -0,0 +1,106 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; + +import type { OpenLayersApi } from './provider.dynamic.osm.openlayers.utils'; +import { + areCoordinatesEqual, + createTileUrlList, + isOpenLayersApi, + toCoordinate, + toLocation, +} from './provider.dynamic.osm.openlayers.utils'; + +const createApi = (): unknown => ({ + Map: jest.fn(), + View: jest.fn(), + control: { + Zoom: jest.fn(), + defaults: { defaults: () => [] }, + }, + interaction: { + defaults: { defaults: () => [] }, + }, + layer: { Tile: jest.fn() }, + proj: { + getUserProjection: () => null, + toLonLat: () => [0, 0], + transform: () => [0, 0], + transformExtent: () => [0, 0, 0, 0], + }, + source: { ImageTile: jest.fn() }, +}); + +describe('OpenLayers utils', () => { + describe('isOpenLayersApi', () => { + it('returns true for a complete OpenLayers API', () => { + expect(isOpenLayersApi(createApi())).toBe(true); + }); + + it('returns false when a required API member is missing', () => { + const api = createApi() as Record; + + delete api.View; + + expect(isOpenLayersApi(api)).toBe(false); + }); + }); + + describe('coordinate conversion', () => { + it('converts a Map location to the active OpenLayers projection', () => { + const transform = jest.fn(() => [100, 200] as [number, number]); + const api = { + proj: { + getUserProjection: () => 'user-projection', + transform, + }, + } as unknown as OpenLayersApi; + + expect(toCoordinate(api, { lat: 40.7, lng: -74 }, 'view-projection')) + .toEqual([100, 200]); + expect(transform).toHaveBeenCalledWith( + [-74, 40.7], + 'EPSG:4326', + 'user-projection', + ); + }); + + it('converts an OpenLayers coordinate to a Map location', () => { + const toLonLat = jest.fn(() => [-74, 40.7] as [number, number]); + const api = { + proj: { + getUserProjection: () => null, + toLonLat, + }, + } as unknown as OpenLayersApi; + + expect(toLocation(api, [100, 200], 'view-projection')) + .toEqual({ lat: 40.7, lng: -74 }); + expect(toLonLat).toHaveBeenCalledWith([100, 200], 'view-projection'); + }); + }); + + describe('areCoordinatesEqual', () => { + it('compares both coordinate values', () => { + expect(areCoordinatesEqual([1, 2], [1, 2])).toBe(true); + expect(areCoordinatesEqual([1, 2], [1, 3])).toBe(false); + expect(areCoordinatesEqual(undefined, [1, 2])).toBe(false); + }); + }); + + describe('createTileUrlList', () => { + it('expands string subdomains and replaces every placeholder', () => { + expect(createTileUrlList('https://{s}.example.com/{s}/{z}', 'ab')).toEqual([ + 'https://a.example.com/a/{z}', + 'https://b.example.com/b/{z}', + ]); + }); + + it('expands array subdomains', () => { + expect(createTileUrlList('https://{s}.example.com/{z}', ['one', 'two'])).toEqual([ + 'https://one.example.com/{z}', + 'https://two.example.com/{z}', + ]); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts new file mode 100644 index 000000000000..dabd818ceecf --- /dev/null +++ b/packages/devextreme/js/__internal/ui/map/provider.dynamic.osm.openlayers.utils.ts @@ -0,0 +1,149 @@ +import type { MapLocation } from '@js/ui/map'; + +import type { MapEngineTileLayerOptions } from './provider.dynamic.osm.engine'; +import { SUBDOMAIN_PLACEHOLDER } from './provider.dynamic.osm.engine'; + +export type Options = Record; +export type Coordinate = [number, number]; +export type Extent = [number, number, number, number]; + +export const GEOGRAPHIC_PROJECTION = 'EPSG:4326'; +export const DEFAULT_VIEW_PROJECTION = 'EPSG:3857'; + +export type ControlLike = object; + +export interface InteractionLike { + getActive: () => boolean; + setActive: (active: boolean) => void; +} + +export interface CollectionLike { + forEach: (callback: (item: T) => void) => void; +} + +export interface ViewLike { + calculateExtent: () => Extent; + fit: (extent: Extent) => void; + getCenter: () => Coordinate | undefined; + getProjection: () => unknown; + getZoom: () => number | undefined; + setCenter: (center: Coordinate) => void; + setZoom: (zoom: number) => void; +} + +export interface TileLayerLike { + setSource: (source: unknown) => void; +} + +export interface MapLike { + addControl: (control: ControlLike) => void; + addLayer: (layer: unknown) => void; + getInteractions: () => CollectionLike; + getView: () => ViewLike; + on: (type: string, listener: (event: unknown) => void) => void; + removeControl: (control: ControlLike) => void; + removeLayer: (layer: unknown) => void; + setTarget: (target?: Element) => void; + un: (type: string, listener: (event: unknown) => void) => void; + updateSize: () => void; +} + +export interface OpenLayersApi { + Map: new (options: Options) => MapLike; + View: new (options: Options) => ViewLike; + control: { + Zoom: new () => ControlLike; + defaults: { + defaults: (options?: Options) => unknown; + }; + }; + interaction: { + defaults: { + defaults: (options?: Options) => unknown; + }; + }; + layer: { + Tile: new (options: Options) => TileLayerLike; + }; + proj: { + getUserProjection: () => unknown | null; + toLonLat: (coordinate: Coordinate, projection?: unknown) => Coordinate; + transform: (coordinate: Coordinate, source: unknown, destination: unknown) => Coordinate; + transformExtent: (extent: Extent, source: unknown, destination: unknown) => Extent; + }; + source: { + ImageTile: new (options: Options) => unknown; + }; +} + +const isRecord = (value: unknown): value is Record => ( + Boolean(value) && (typeof value === 'object' || typeof value === 'function') +); + +const hasFunction = (value: unknown, property: string): boolean => ( + isRecord(value) && typeof value[property] === 'function' +); + +export const isOpenLayersApi = (api: unknown): api is OpenLayersApi => { + if (!isRecord(api)) { + return false; + } + + return typeof api.Map === 'function' + && typeof api.View === 'function' + && isRecord(api.control) + && hasFunction(api.control, 'Zoom') + && isRecord(api.control.defaults) + && hasFunction(api.control.defaults, 'defaults') + && isRecord(api.interaction) + && isRecord(api.interaction.defaults) + && hasFunction(api.interaction.defaults, 'defaults') + && hasFunction(api.layer, 'Tile') + && hasFunction(api.proj, 'getUserProjection') + && hasFunction(api.proj, 'toLonLat') + && hasFunction(api.proj, 'transform') + && hasFunction(api.proj, 'transformExtent') + && hasFunction(api.source, 'ImageTile'); +}; + +export const getCoordinateProjection = ( + api: OpenLayersApi, + viewProjection: unknown, +): unknown => api.proj.getUserProjection() ?? viewProjection; + +export const areCoordinatesEqual = ( + first: Coordinate | undefined, + second: Coordinate, +): boolean => first?.[0] === second[0] && first[1] === second[1]; + +export const toCoordinate = ( + api: OpenLayersApi, + location: MapLocation, + viewProjection: unknown, +): Coordinate => api.proj.transform( + [location.lng, location.lat], + GEOGRAPHIC_PROJECTION, + getCoordinateProjection(api, viewProjection), +); + +export const toLocation = ( + api: OpenLayersApi, + coordinate: Coordinate, + viewProjection: unknown, +): MapLocation => { + const [lng, lat] = api.proj.toLonLat( + coordinate, + getCoordinateProjection(api, viewProjection), + ); + + return { lat, lng }; +}; + +export const createTileUrlList = ( + url: string, + subdomains: MapEngineTileLayerOptions['subdomains'], +): string[] => { + const values = Array.isArray(subdomains) ? subdomains : [...(subdomains ?? '')]; + + return values.map((value) => url.split(SUBDOMAIN_PLACEHOLDER).join(value)); +}; From 1e9eb59f057a2b1fd849b23aed81972fdf670f21 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 28 Aug 2026 13:17:00 +0300 Subject: [PATCH 07/11] Map: derive OSM keyboard state from options --- .../ui/map/provider.dynamic.osm.openlayers.ts | 40 +++-------------- .../mapParts/osmTests.js | 45 ------------------- 2 files changed, 5 insertions(+), 80 deletions(-) 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 41b158361a52..f0cf0a911f72 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 @@ -40,8 +40,6 @@ class OpenLayersMap implements MapEngineMap { private readonly _container: Element; - private _ownedKeyboardTabIndex: string | null | undefined; - private _focusEnabled = true; private _tabIndex = 0; @@ -52,8 +50,6 @@ class OpenLayersMap implements MapEngineMap { private _disabled = false; - private _ownsDisabledInert = false; - private readonly _interactionStates = new Map(); private _eventHandlers?: { @@ -71,7 +67,6 @@ class OpenLayersMap implements MapEngineMap { view: MapEngineSetViewOptions = {}, ) { this._container = container; - this._ownedKeyboardTabIndex = container.hasAttribute('tabindex') ? undefined : null; this._syncKeyboardTabIndex(); this.originalMap = new _api.Map({ @@ -166,17 +161,14 @@ class OpenLayersMap implements MapEngineMap { this._disposed = true; this._detachHandlers(); - this._removeOwnedInert(); + this._container.removeAttribute('inert'); this.setControls(false); if (this._tileLayer) { this.originalMap.removeLayer(this._tileLayer); this._tileLayer = undefined; } this.originalMap.setTarget(undefined); - if (this._ownedKeyboardTabIndex !== undefined - && this._container.getAttribute('tabindex') === this._ownedKeyboardTabIndex) { - this._container.removeAttribute('tabindex'); - } + this._container.removeAttribute('tabindex'); } fitBounds(bounds: MapEngineBounds): void { @@ -263,25 +255,15 @@ class OpenLayersMap implements MapEngineMap { } private _disableKeyboardAccess(): void { - if (!this._container.hasAttribute('inert')) { - this._container.setAttribute('inert', ''); - this._ownsDisabledInert = true; - } + this._container.setAttribute('inert', ''); this._syncKeyboardTabIndex(); } private _restoreKeyboardAccess(): void { - this._removeOwnedInert(); + this._container.removeAttribute('inert'); this._syncKeyboardTabIndex(); } - private _removeOwnedInert(): void { - if (this._ownsDisabledInert) { - this._container.removeAttribute('inert'); - this._ownsDisabledInert = false; - } - } - setFocus(enabled: boolean, tabIndex: number): void { this._focusEnabled = enabled; this._tabIndex = tabIndex; @@ -289,22 +271,10 @@ class OpenLayersMap implements MapEngineMap { } private _syncKeyboardTabIndex(): void { - if (this._ownedKeyboardTabIndex === undefined) { - return; - } - - if (this._container.getAttribute('tabindex') !== this._ownedKeyboardTabIndex) { - this._ownedKeyboardTabIndex = undefined; - return; - } - if (this._focusEnabled && !this._disabled) { - const tabIndex = String(this._tabIndex); - this._container.setAttribute('tabindex', tabIndex); - this._ownedKeyboardTabIndex = tabIndex; + this._container.setAttribute('tabindex', String(this._tabIndex)); } else { this._container.removeAttribute('tabindex'); - this._ownedKeyboardTabIndex = null; } } 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 32f1ed741a01..18b5e994a39a 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -177,24 +177,6 @@ QUnit.module('OSM: map loading', moduleConfig, () => { engineMap.dispose(); assert.strictEqual(container.getAttribute('tabindex'), null, 'added map target tabindex is removed on dispose'); }); - QUnit.test('existing tabindex is preserved on dispose', function(assert) { - const engine = createOpenLayersEngine(openLayersMock); - const container = document.createElement('div'); - container.setAttribute('tabindex', '-1'); - const engineMap = engine.createMap(container); - engineMap.dispose(); - assert.strictEqual(container.getAttribute('tabindex'), '-1', 'client tabindex is preserved'); - }); - QUnit.test('existing inert attribute is preserved after disabled is toggled', function(assert) { - const engine = createOpenLayersEngine(openLayersMock); - const container = document.createElement('div'); - container.setAttribute('inert', ''); - const engineMap = engine.createMap(container); - engineMap.setDisabled(true); - engineMap.setDisabled(false); - assert.ok(container.hasAttribute('inert'), 'client inert attribute is preserved'); - engineMap.dispose(); - }); QUnit.test('owned inert attribute is removed on dispose', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const container = document.createElement('div'); @@ -222,33 +204,6 @@ QUnit.module('OSM: map loading', moduleConfig, () => { assert.strictEqual(container.getAttribute('tabindex'), '0', 'owned map target tabindex is restored'); engineMap.dispose(); }); - QUnit.test('tabindex changed after initialization is preserved on dispose', function(assert) { - const engine = createOpenLayersEngine(openLayersMock); - const container = document.createElement('div'); - const engineMap = engine.createMap(container); - container.setAttribute('tabindex', '-1'); - engineMap.dispose(); - assert.strictEqual(container.getAttribute('tabindex'), '-1', 'updated client tabindex is preserved'); - }); - QUnit.test('tabindex changed after initialization is preserved on runtime focus updates', function(assert) { - const engine = createOpenLayersEngine(openLayersMock); - const container = document.createElement('div'); - const engineMap = engine.createMap(container); - container.setAttribute('tabindex', '-1'); - engineMap.setFocus(true, 5); - assert.strictEqual(container.getAttribute('tabindex'), '-1', 'updated client tabindex is preserved'); - engineMap.dispose(); - }); - QUnit.test('tabindex changed after initialization is preserved when disabled is toggled', function(assert) { - const engine = createOpenLayersEngine(openLayersMock); - const container = document.createElement('div'); - const engineMap = engine.createMap(container); - container.setAttribute('tabindex', '-1'); - engineMap.setDisabled(true); - engineMap.setDisabled(false); - assert.strictEqual(container.getAttribute('tabindex'), '-1', 'updated client tabindex is preserved'); - engineMap.dispose(); - }); QUnit.test('engine map can be disposed more than once', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const engineMap = engine.createMap(document.createElement('div')); From de8fe0acddf4acb07d4dbf7b3b4a73199d9d1952 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 28 Aug 2026 13:18:45 +0300 Subject: [PATCH 08/11] Map: await OSM updates through test hooks --- .../testing/helpers/forMap/openLayersMock.js | 3 + .../mapParts/osmTests.js | 80 +++++++++++-------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/packages/devextreme/testing/helpers/forMap/openLayersMock.js b/packages/devextreme/testing/helpers/forMap/openLayersMock.js index 17a86264b898..64104646129c 100644 --- a/packages/devextreme/testing/helpers/forMap/openLayersMock.js +++ b/packages/devextreme/testing/helpers/forMap/openLayersMock.js @@ -46,6 +46,9 @@ setActive(active) { this.active = active; api.interactionStateChanges.push({ interaction: this, active }); + if(api.onInteractionStateChanged) { + api.onInteractionStateChanged(); + } } } 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 18b5e994a39a..75764b03d2fb 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -23,6 +23,7 @@ const resetOpenLayersMock = () => { mapOptions: null, mapResized: false, mapTarget: null, + onInteractionStateChanged: null, projectedCoordinates: [], removedControls: [], removedLayers: [], @@ -43,6 +44,18 @@ const resetOpenLayersMock = () => { zoomControlCreatedCount: 0 }); }; +const onInteractionStates = (expectedStates, callback) => { + openLayersMock.onInteractionStateChanged = () => { + const actualStates = openLayersMock.interactions.map(interaction => interaction.getActive()); + const stateMatches = actualStates.length === expectedStates.length + && actualStates.every((state, index) => state === expectedStates[index]); + + if(stateMatches) { + openLayersMock.onInteractionStateChanged = null; + callback(); + } + }; +}; const moduleConfig = { beforeEach(assert) { const setup = () => { @@ -766,19 +779,19 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { onReady: () => { const target = getOpenLayersKeyboardTarget(); assert.strictEqual(target.getAttribute('tabindex'), null, 'focus is disabled on initialization'); - map.option('focusStateEnabled', true); - map._lastAsyncAction.then(() => { + map.option('onUpdated', () => { assert.strictEqual(target.getAttribute('tabindex'), '5', 'configured tabIndex is applied'); - map.option('tabIndex', -1); - map._lastAsyncAction.then(() => { + map.option('onUpdated', () => { assert.strictEqual(target.getAttribute('tabindex'), '-1', 'runtime tabIndex is applied'); - map.option('focusStateEnabled', false); - map._lastAsyncAction.then(() => { + map.option('onUpdated', () => { assert.strictEqual(target.getAttribute('tabindex'), null, 'runtime focus disabling is applied'); done(); }); + map.option('focusStateEnabled', false); }); + map.option('tabIndex', -1); }); + map.option('focusStateEnabled', true); } }).dxMap('instance'); }); @@ -896,6 +909,10 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { lng: -74.1 } }, 'bounds are synchronized from the user projection'); + map.option('onUpdated', () => { + assert.deepEqual(openLayersMock.fittedExtent, [-74, 40.7, -73.9, 40.8], 'bounds are fitted in the user projection'); + done(); + }); map.option('bounds', { northEast: { lat: 40.8, @@ -906,10 +923,6 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { lng: -74 } }); - map._lastAsyncAction.then(() => { - assert.deepEqual(openLayersMock.fittedExtent, [-74, 40.7, -73.9, 40.8], 'bounds are fitted in the user projection'); - done(); - }); } }).dxMap('instance'); }); @@ -984,15 +997,15 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { }, onReady: () => { assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is added on initialization'); - map.option('controls', false); - map._lastAsyncAction.then(() => { + map.option('onUpdated', () => { assert.strictEqual(openLayersMock.removedControls.length, 1, 'zoom control is removed'); - map.option('controls', true); - map._lastAsyncAction.then(() => { + map.option('onUpdated', () => { assert.strictEqual(openLayersMock.addedControls.length, 2, 'zoom control is added again'); done(); }); + map.option('controls', true); }); + map.option('controls', false); } }).dxMap('instance'); }); @@ -1011,24 +1024,32 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is present'); assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [true, false], 'initial states are preserved'); assert.notOk(getOpenLayersMapTarget().hasAttribute('inert'), 'map target is keyboard accessible'); - map.option('disabled', true); - map._lastAsyncAction.then(() => { + onInteractionStates([false, false], () => { assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [false, false], 'all interactions are disabled'); assert.ok(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls are removed from keyboard navigation'); assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), null, 'owned keyboard target tabindex is removed'); - map.option('disabled', false); - map._lastAsyncAction.then(() => { + onInteractionStates([true, false], () => { assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [true, false], 'previous states are restored'); assert.notOk(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls return to keyboard navigation'); assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), '0', 'owned keyboard target tabindex is restored'); done(); }); + map.option('disabled', false); }); + map.option('disabled', true); } }).dxMap('instance'); }); QUnit.test('disabled option is applied on initialization', function(assert) { const done = assert.async(); + onInteractionStates([false, false], () => { + assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is present'); + assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [false, false], 'all interactions are disabled'); + assert.ok(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls are removed from keyboard navigation'); + assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), null, 'owned keyboard target tabindex is removed'); + map.option('onUpdated', done); + map.option('disabled', false); + }); const map = $('#map').dxMap({ provider: 'osm', controls: true, @@ -1040,13 +1061,6 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { } } }).dxMap('instance'); - map._lastAsyncAction.then(() => { - assert.strictEqual(openLayersMock.addedControls.length, 1, 'zoom control is present'); - assert.deepEqual(openLayersMock.interactions.map(interaction => interaction.getActive()), [false, false], 'all interactions are disabled'); - assert.ok(getOpenLayersMapTarget().hasAttribute('inert'), 'map target and controls are removed from keyboard navigation'); - assert.strictEqual(getOpenLayersKeyboardTarget().getAttribute('tabindex'), null, 'owned keyboard target tabindex is removed'); - done(); - }); }); QUnit.test('bounds option fits the OpenLayers view on initialization and at runtime', function(assert) { const done = assert.async(); @@ -1071,14 +1085,14 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { onReady: () => { assert.deepEqual(openLayersMock.fittedExtent, [-74000, 40700, -73900, 40800], 'initial bounds are fitted'); assert.strictEqual(openLayersMock.fitOptions, undefined, 'OpenLayers selects the viewport size'); + map.option('onUpdated', () => { + assert.deepEqual(openLayersMock.fittedExtent, [-74100, 40600, -73800, 40900], 'runtime bounds are fitted'); + done(); + }); map.option('bounds', { northEast: [40.9, -73.8], southWest: [40.6, -74.1] }); - map._lastAsyncAction.then(() => { - assert.deepEqual(openLayersMock.fittedExtent, [-74100, 40600, -73800, 40900], 'runtime bounds are fitted'); - done(); - }); } }).dxMap('instance'); }); @@ -1093,6 +1107,10 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { } }, onReady: () => { + map.option('onUpdated', () => { + assert.strictEqual(openLayersMock.fittedExtent, null, 'view is not fitted without both bounds'); + done(); + }); map.option('bounds', { northEast: { lat: 40.8, @@ -1100,10 +1118,6 @@ QUnit.module('OSM: viewport and interactions', moduleConfig, () => { }, southWest: null }); - map._lastAsyncAction.then(() => { - assert.strictEqual(openLayersMock.fittedExtent, null, 'view is not fitted without both bounds'); - done(); - }); } }).dxMap('instance'); }); From 81bc4ca91433761cf70de7cf1e8a0466fbd63751 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 28 Aug 2026 14:29:09 +0300 Subject: [PATCH 09/11] Map: extract OpenLayers handler initialization --- .../js/__internal/ui/map/provider.dynamic.osm.openlayers.ts | 3 +++ 1 file changed, 3 insertions(+) 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 f0cf0a911f72..8a45f9723bef 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 @@ -93,7 +93,10 @@ class OpenLayersMap implements MapEngineMap { attachHandlers(handlers: MapEngineEventHandlers): void { this._detachHandlers(); + this._initHandlers(handlers); + } + private _initHandlers(handlers: MapEngineEventHandlers): void { const click = (event: unknown): void => { const { coordinate, originalEvent } = event as MapBrowserEventLike; if (!coordinate) { From b4e33c4963464b6aa4463f65d780687f6cda600a Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 28 Aug 2026 15:37:45 +0300 Subject: [PATCH 10/11] Map: preserve pre-existing inert state --- .../ui/map/provider.dynamic.osm.openlayers.ts | 18 +++++++++++++++--- .../DevExpress.ui.widgets/mapParts/osmTests.js | 13 +++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) 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 8a45f9723bef..c33e0d8cea10 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 @@ -50,6 +50,8 @@ class OpenLayersMap implements MapEngineMap { private _disabled = false; + private _ownsDisabledInert = false; + private readonly _interactionStates = new Map(); private _eventHandlers?: { @@ -164,7 +166,7 @@ class OpenLayersMap implements MapEngineMap { this._disposed = true; this._detachHandlers(); - this._container.removeAttribute('inert'); + this._removeOwnedInert(); this.setControls(false); if (this._tileLayer) { this.originalMap.removeLayer(this._tileLayer); @@ -258,15 +260,25 @@ class OpenLayersMap implements MapEngineMap { } private _disableKeyboardAccess(): void { - this._container.setAttribute('inert', ''); + if (!this._container.hasAttribute('inert')) { + this._container.setAttribute('inert', ''); + this._ownsDisabledInert = true; + } this._syncKeyboardTabIndex(); } private _restoreKeyboardAccess(): void { - this._container.removeAttribute('inert'); + this._removeOwnedInert(); this._syncKeyboardTabIndex(); } + private _removeOwnedInert(): void { + if (this._ownsDisabledInert) { + this._container.removeAttribute('inert'); + this._ownsDisabledInert = false; + } + } + setFocus(enabled: boolean, tabIndex: number): void { this._focusEnabled = enabled; this._tabIndex = tabIndex; 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 75764b03d2fb..52dd179dd79c 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/mapParts/osmTests.js @@ -199,6 +199,19 @@ QUnit.module('OSM: map loading', moduleConfig, () => { engineMap.dispose(); assert.notOk(container.hasAttribute('inert'), 'owned inert attribute is removed'); }); + QUnit.test('pre-existing inert attribute is preserved', function(assert) { + const engine = createOpenLayersEngine(openLayersMock); + const container = document.createElement('div'); + container.setAttribute('inert', ''); + const engineMap = engine.createMap(container); + + engineMap.setDisabled(true); + engineMap.setDisabled(false); + assert.ok(container.hasAttribute('inert'), 'pre-existing inert attribute is preserved after enabling'); + + engineMap.dispose(); + assert.ok(container.hasAttribute('inert'), 'pre-existing inert attribute is preserved on dispose'); + }); QUnit.test('disabled map does not make its Shadow DOM host inert', function(assert) { const engine = createOpenLayersEngine(openLayersMock); const host = document.createElement('div'); From a0a1f63af3f80c011f6c72b173e284e9d6314c50 Mon Sep 17 00:00:00 2001 From: AlisherAmonulloev Date: Fri, 28 Aug 2026 17:41:06 +0300 Subject: [PATCH 11/11] Map: simplify OSM Storybook center control --- .../stories/map/OSMMap.stories.tsx | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/apps/react-storybook/stories/map/OSMMap.stories.tsx b/apps/react-storybook/stories/map/OSMMap.stories.tsx index ffe3600dcb2e..865b6130cd0e 100644 --- a/apps/react-storybook/stories/map/OSMMap.stories.tsx +++ b/apps/react-storybook/stories/map/OSMMap.stories.tsx @@ -7,7 +7,7 @@ import 'ol/ol.css'; import { fromLonLat, transformExtent } from 'ol/proj.js'; import View from 'ol/View.js'; import React from 'react'; -import Map from 'devextreme-react/map'; +import Map, { type MapRef } from 'devextreme-react/map'; import type { MapLocation, MapType, @@ -16,6 +16,7 @@ import type { import 'devextreme/ui/map/openlayers'; const CENTER = { lat: 40.7484, lng: -73.9857 }; +const CENTRAL_PARK_CENTER = { lat: 40.7829, lng: -73.9654 }; const EXTENT: [number, number, number, number] = [-74.08, 40.67, -73.85, 40.88]; const TILE_SERVER = { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', @@ -25,7 +26,7 @@ const TILE_SERVER = { const PROVIDER_CONFIG = { tileServer: () => TILE_SERVER }; interface OsmStoryArgs { - center: MapLocation; + centerOnCentralPark: boolean; controls: boolean; disabled: boolean; focusStateEnabled: boolean; @@ -60,7 +61,7 @@ const configureOpenLayersMap = ( }; const OsmMapStory = ({ - center, + centerOnCentralPark, controls, disabled, focusStateEnabled, @@ -68,24 +69,33 @@ const OsmMapStory = ({ type, updateArgs, zoom, -}: OsmMapStoryProps): React.ReactElement => ( - updateArgs({ center: value as MapLocation })} - onReady={(event) => configureOpenLayersMap(event, center, zoom)} - onZoomChange={(value) => updateArgs({ zoom: value })} - /> -); +}: OsmMapStoryProps): React.ReactElement => { + const mapRef = React.useRef(null); + const center = centerOnCentralPark ? CENTRAL_PARK_CENTER : CENTER; + + React.useEffect(() => { + mapRef.current?.instance()?.option('center', center); + }, [center]); + + return ( + configureOpenLayersMap(event, center, zoom)} + onZoomChange={(value) => updateArgs({ zoom: value })} + /> + ); +}; const meta: Meta = { title: 'Components/Map/OSM Provider', @@ -99,8 +109,9 @@ const meta: Meta = { layout: 'fullscreen', }, argTypes: { - center: { - control: 'object', + centerOnCentralPark: { + control: 'boolean', + description: 'Switches the map center between the default New York location and Central Park.', }, controls: { control: 'boolean', @@ -137,7 +148,7 @@ type Story = StoryObj; export const Default: Story = { args: { - center: CENTER, + centerOnCentralPark: false, controls: true, disabled: false, focusStateEnabled: true,