diff --git a/src/dev-app/google-map/google-map-demo.html b/src/dev-app/google-map/google-map-demo.html index 588e0a77432d..afaecc973b46 100644 --- a/src/dev-app/google-map/google-map-demo.html +++ b/src/dev-app/google-map/google-map-demo.html @@ -7,10 +7,12 @@ (mapMousemove)="handleMove($event)" (mapRightclick)="handleRightclick()"> - + + Testing 1 2 3
Latitude: {{display?.lat}}
diff --git a/src/dev-app/google-map/google-map-demo.ts b/src/dev-app/google-map/google-map-demo.ts index 163c8573f39e..91d41c0ea06e 100644 --- a/src/dev-app/google-map/google-map-demo.ts +++ b/src/dev-app/google-map/google-map-demo.ts @@ -6,8 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import {Component} from '@angular/core'; import {HttpClient} from '@angular/common/http'; +import {Component, ViewChild} from '@angular/core'; +import {MapInfoWindow, MapMarker} from '@angular/google-maps'; /** Demo Component for @angular/google-maps/map */ @Component({ @@ -16,11 +17,14 @@ import {HttpClient} from '@angular/common/http'; templateUrl: 'google-map-demo.html', }) export class GoogleMapDemo { + @ViewChild(MapInfoWindow, {static: false}) infoWindow: MapInfoWindow; + isReady = false; center = {lat: 24, lng: 12}; markerOptions = {draggable: false}; markerPositions: google.maps.LatLngLiteral[] = []; + infoWindowPosition: google.maps.LatLngLiteral; zoom = 4; display?: google.maps.LatLngLiteral; @@ -39,9 +43,8 @@ export class GoogleMapDemo { this.display = event.latLng.toJSON(); } - clickMarker(event: google.maps.MouseEvent) { - console.log(this.markerOptions); - this.markerOptions = {draggable: true}; + clickMarker(marker: MapMarker) { + this.infoWindow.open(marker); } handleRightclick() { diff --git a/src/google-maps/BUILD.bazel b/src/google-maps/BUILD.bazel index 2437ae7a759e..0ee91e2d76bf 100644 --- a/src/google-maps/BUILD.bazel +++ b/src/google-maps/BUILD.bazel @@ -13,6 +13,7 @@ ng_module( deps = [ "@npm//@angular/core", "@npm//@types/googlemaps", + "@npm//rxjs", ], ) diff --git a/src/google-maps/google-map/google-map.ts b/src/google-maps/google-map/google-map.ts index 049c98d7f929..81963bf8f4b2 100644 --- a/src/google-maps/google-map/google-map.ts +++ b/src/google-maps/google-map/google-map.ts @@ -191,7 +191,7 @@ export class GoogleMap implements OnChanges, OnInit, AfterContentInit, OnDestroy @ContentChildren(MapMarker) _markers: QueryList; private _mapEl: HTMLElement; - private _googleMap!: UpdatedGoogleMap; + _googleMap!: UpdatedGoogleMap; private _googleMapChanges!: Observable; diff --git a/src/google-maps/google-maps-module.ts b/src/google-maps/google-maps-module.ts index d816f00546ad..28c81e47a38a 100644 --- a/src/google-maps/google-maps-module.ts +++ b/src/google-maps/google-maps-module.ts @@ -8,16 +8,19 @@ import {NgModule} from '@angular/core'; -import {MapMarker, MapMarkerModule} from './map-marker/index'; import {GoogleMap, GoogleMapModule} from './google-map/index'; +import {MapInfoWindow, MapInfoWindowModule} from './map-info-window/index'; +import {MapMarker, MapMarkerModule} from './map-marker/index'; @NgModule({ imports: [ GoogleMapModule, + MapInfoWindowModule, MapMarkerModule, ], exports: [ GoogleMap, + MapInfoWindow, MapMarker, ], }) diff --git a/src/google-maps/map-info-window/index.ts b/src/google-maps/map-info-window/index.ts new file mode 100644 index 000000000000..46f6e6539a4d --- /dev/null +++ b/src/google-maps/map-info-window/index.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './map-info-window'; +export * from './map-info-window-module'; diff --git a/src/google-maps/map-info-window/map-info-window-module.ts b/src/google-maps/map-info-window/map-info-window-module.ts new file mode 100644 index 000000000000..de7eed77138b --- /dev/null +++ b/src/google-maps/map-info-window/map-info-window-module.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {MapInfoWindow} from './map-info-window'; + +@NgModule({ + exports: [MapInfoWindow], + declarations: [MapInfoWindow], +}) +export class MapInfoWindowModule { +} diff --git a/src/google-maps/map-info-window/map-info-window.spec.ts b/src/google-maps/map-info-window/map-info-window.spec.ts new file mode 100644 index 000000000000..c7f488434c37 --- /dev/null +++ b/src/google-maps/map-info-window/map-info-window.spec.ts @@ -0,0 +1,185 @@ +import {Component} from '@angular/core'; +import {async, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; + +import {DEFAULT_OPTIONS, GoogleMapModule, UpdatedGoogleMap} from '../google-map/index'; +import {MapMarker} from '../map-marker/index'; +import { + createInfoWindowConstructorSpy, + createInfoWindowSpy, + createMapConstructorSpy, + createMapSpy, + TestingWindow +} from '../testing/fake-google-map-utils'; + +import {MapInfoWindow, MapInfoWindowModule} from './index'; + +describe('MapInfoWindow', () => { + let mapSpy: jasmine.SpyObj; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + GoogleMapModule, + MapInfoWindowModule, + ], + declarations: [TestApp], + }); + })); + + beforeEach(() => { + TestBed.compileComponents(); + + mapSpy = createMapSpy(DEFAULT_OPTIONS); + createMapConstructorSpy(mapSpy).and.callThrough(); + }); + + afterEach(() => { + const testingWindow: TestingWindow = window; + delete testingWindow.google; + }); + + it('initializes a Google Map Info Window', () => { + const infoWindowSpy = createInfoWindowSpy({}); + const infoWindowConstructorSpy = + createInfoWindowConstructorSpy(infoWindowSpy).and.callThrough(); + + const fixture = TestBed.createComponent(TestApp); + fixture.detectChanges(); + + expect(infoWindowConstructorSpy).toHaveBeenCalledWith({ + position: undefined, + content: jasmine.any(Node), + }); + }); + + it('sets position', () => { + const position: google.maps.LatLngLiteral = {lat: 5, lng: 7}; + const infoWindowSpy = createInfoWindowSpy({position}); + const infoWindowConstructorSpy = + createInfoWindowConstructorSpy(infoWindowSpy).and.callThrough(); + + const fixture = TestBed.createComponent(TestApp); + fixture.componentInstance.position = position; + fixture.detectChanges(); + + expect(infoWindowConstructorSpy).toHaveBeenCalledWith({ + position, + content: jasmine.any(Node), + }); + }); + + it('sets options', () => { + const options: google.maps.InfoWindowOptions = { + position: {lat: 3, lng: 5}, + maxWidth: 50, + disableAutoPan: true, + }; + const infoWindowSpy = createInfoWindowSpy(options); + const infoWindowConstructorSpy = + createInfoWindowConstructorSpy(infoWindowSpy).and.callThrough(); + + const fixture = TestBed.createComponent(TestApp); + fixture.componentInstance.options = options; + fixture.detectChanges(); + + expect(infoWindowConstructorSpy).toHaveBeenCalledWith({ + ...options, + content: jasmine.any(Node), + }); + }); + + it('gives preference to position over options', () => { + const position: google.maps.LatLngLiteral = {lat: 5, lng: 7}; + const options: google.maps.InfoWindowOptions = { + position: {lat: 3, lng: 5}, + maxWidth: 50, + disableAutoPan: true, + }; + const infoWindowSpy = createInfoWindowSpy({...options, position}); + const infoWindowConstructorSpy = + createInfoWindowConstructorSpy(infoWindowSpy).and.callThrough(); + + const fixture = TestBed.createComponent(TestApp); + fixture.componentInstance.options = options; + fixture.componentInstance.position = position; + fixture.detectChanges(); + + expect(infoWindowConstructorSpy).toHaveBeenCalledWith({ + ...options, + position, + content: jasmine.any(Node), + }); + }); + + it('exposes methods that change the configuration of the info window', () => { + const fakeMarker = {} as unknown as google.maps.Marker; + const fakeMarkerComponent = {_marker: fakeMarker} as unknown as MapMarker; + const infoWindowSpy = createInfoWindowSpy({}); + createInfoWindowConstructorSpy(infoWindowSpy).and.callThrough(); + + const fixture = TestBed.createComponent(TestApp); + const infoWindowComponent = fixture.debugElement.query(By.directive( + MapInfoWindow))!.injector.get(MapInfoWindow); + fixture.detectChanges(); + + infoWindowComponent.close(); + expect(infoWindowSpy.close).toHaveBeenCalled(); + + infoWindowComponent.open(fakeMarkerComponent); + expect(infoWindowSpy.open).toHaveBeenCalledWith(mapSpy, fakeMarker); + }); + + it('exposes methods that provide information about the info window', () => { + const infoWindowSpy = createInfoWindowSpy({}); + createInfoWindowConstructorSpy(infoWindowSpy).and.callThrough(); + + const fixture = TestBed.createComponent(TestApp); + const infoWindowComponent = fixture.debugElement.query(By.directive( + MapInfoWindow))!.injector.get(MapInfoWindow); + fixture.detectChanges(); + + infoWindowSpy.getContent.and.returnValue('test content'); + expect(infoWindowComponent.getContent()).toBe('test content'); + + infoWindowComponent.getPosition(); + expect(infoWindowSpy.getPosition).toHaveBeenCalled(); + + infoWindowSpy.getZIndex.and.returnValue(5); + expect(infoWindowComponent.getZIndex()).toBe(5); + }); + + it('initializes info window event handlers', () => { + const infoWindowSpy = createInfoWindowSpy({}); + createInfoWindowConstructorSpy(infoWindowSpy).and.callThrough(); + + const fixture = TestBed.createComponent(TestApp); + fixture.detectChanges(); + + expect(infoWindowSpy.addListener).toHaveBeenCalledWith('closeclick', jasmine.any(Function)); + expect(infoWindowSpy.addListener) + .not.toHaveBeenCalledWith('content_changed', jasmine.any(Function)); + expect(infoWindowSpy.addListener).not.toHaveBeenCalledWith('domready', jasmine.any(Function)); + expect(infoWindowSpy.addListener) + .not.toHaveBeenCalledWith('position_changed', jasmine.any(Function)); + expect(infoWindowSpy.addListener) + .not.toHaveBeenCalledWith('zindex_changed', jasmine.any(Function)); + }); +}); + +@Component({ + selector: 'test-app', + template: ` + + test content + + `, +}) +class TestApp { + position?: google.maps.LatLngLiteral; + options?: google.maps.InfoWindowOptions; + + handleClose() {} +} diff --git a/src/google-maps/map-info-window/map-info-window.ts b/src/google-maps/map-info-window/map-info-window.ts new file mode 100644 index 000000000000..11136f8c82e7 --- /dev/null +++ b/src/google-maps/map-info-window/map-info-window.ts @@ -0,0 +1,181 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Directive, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; +import {BehaviorSubject, combineLatest, Observable, Subject} from 'rxjs'; +import {map, takeUntil} from 'rxjs/operators'; + +import {GoogleMap} from '../google-map/index'; +import {MapMarker} from '../map-marker/index'; + +/** + * Angular component that renders a Google Maps info window via the Google Maps JavaScript API. + * @see developers.google.com/maps/documentation/javascript/reference/info-window + */ +@Directive({ + selector: 'map-info-window', + host: {'style': 'display: none'}, +}) +export class MapInfoWindow implements OnInit, OnDestroy { + @Input() + set options(options: google.maps.InfoWindowOptions) { + this._options.next(options || {}); + } + + @Input() + set position(position: google.maps.LatLngLiteral) { + this._position.next(position); + } + + /** + * See + * developers.google.com/maps/documentation/javascript/reference/info-window#InfoWindow.closeclick + */ + @Output() closeclick = new EventEmitter(); + + /** + * See + * developers.google.com/maps/documentation/javascript/reference/info-window + * #InfoWindow.content_changed + */ + @Output() contentChanged = new EventEmitter(); + + /** + * See + * developers.google.com/maps/documentation/javascript/reference/info-window#InfoWindow.domready + */ + @Output() domready = new EventEmitter(); + + /** + * See + * developers.google.com/maps/documentation/javascript/reference/info-window + * #InfoWindow.position_changed + */ + @Output() positionChanged = new EventEmitter(); + + /** + * See + * developers.google.com/maps/documentation/javascript/reference/info-window + * #InfoWindow.zindex_changed + */ + @Output() zindexChanged = new EventEmitter(); + + private readonly _options = new BehaviorSubject({}); + private readonly _position = new BehaviorSubject(undefined); + + private readonly _listeners: google.maps.MapsEventListener[] = []; + + private readonly _destroy = new Subject(); + + private _infoWindow?: google.maps.InfoWindow; + + constructor(private readonly googleMap: GoogleMap, private _elementRef: ElementRef) { + } + + ngOnInit() { + this._combineOptions().pipe(takeUntil(this._destroy)).subscribe(options => { + if (this._infoWindow) { + this._infoWindow.setOptions(options); + } else { + this._infoWindow = new google.maps.InfoWindow(options); + this._initializeEventHandlers(); + } + }); + } + + ngOnDestroy() { + this._destroy.next(); + this._destroy.complete(); + for (let listener of this._listeners) { + listener.remove(); + } + this.close(); + } + + /** + * See developers.google.com/maps/documentation/javascript/reference/info-window#InfoWindow.close + */ + close() { + if (this._infoWindow) { + this._infoWindow.close(); + } + } + + /** + * See + * developers.google.com/maps/documentation/javascript/reference/info-window#InfoWindow.getContent + */ + getContent(): string|Node { + return this._infoWindow!.getContent(); + } + + /** + * See + * developers.google.com/maps/documentation/javascript/reference/info-window + * #InfoWindow.getPosition + */ + getPosition(): google.maps.LatLng|null { + return this._infoWindow!.getPosition() || null; + } + + /** + * See + * developers.google.com/maps/documentation/javascript/reference/info-window#InfoWindow.getZIndex + */ + getZIndex(): number { + return this._infoWindow!.getZIndex(); + } + + /** + * Opens the MapInfoWindow using the provided MapMarker as the anchor. If the anchor is not set, + * then the position property of the options input is used instead. + */ + open(anchor?: MapMarker) { + const marker = anchor ? anchor._marker : undefined; + if (this.googleMap._googleMap) { + this._elementRef.nativeElement.style.display = ''; + this._infoWindow!.open(this.googleMap._googleMap, marker); + } + } + + private _combineOptions(): Observable { + return combineLatest(this._options, this._position).pipe(map(([options, position]) => { + const combinedOptions: google.maps.InfoWindowOptions = { + ...options, + position: position || options.position, + content: this._elementRef.nativeElement, + }; + return combinedOptions; + })); + } + + private _initializeEventHandlers() { + const eventHandlers = new Map>([ + ['closeclick', this.closeclick], + ['content_changed', this.contentChanged], + ['domready', this.domready], + ['position_changed', this.positionChanged], + ['zindex_changed', this.zindexChanged], + ]); + eventHandlers.forEach((eventHandler: EventEmitter, name: string) => { + if (eventHandler.observers.length > 0) { + this._listeners.push(this._infoWindow!.addListener(name, () => { + eventHandler.emit(); + })); + } + }); + } +} diff --git a/src/google-maps/map-marker/map-marker.ts b/src/google-maps/map-marker/map-marker.ts index cbf69381c4c8..5ff6b6b4f8bd 100644 --- a/src/google-maps/map-marker/map-marker.ts +++ b/src/google-maps/map-marker/map-marker.ts @@ -204,9 +204,10 @@ export class MapMarker implements OnInit, OnDestroy { private readonly _listeners: google.maps.MapsEventListener[] = []; - private _marker?: google.maps.Marker; private _hasMap = false; + _marker?: google.maps.Marker; + ngOnInit() { const combinedOptionsChanges = this._combineOptions(); diff --git a/src/google-maps/public-api.ts b/src/google-maps/public-api.ts index bd57b5427d97..a16afad5b4df 100644 --- a/src/google-maps/public-api.ts +++ b/src/google-maps/public-api.ts @@ -7,5 +7,6 @@ */ export {GoogleMap} from './google-map/index'; +export {MapInfoWindow} from './map-info-window/index'; export {MapMarker} from './map-marker/index'; export * from './google-maps-module'; diff --git a/src/google-maps/testing/fake-google-map-utils.ts b/src/google-maps/testing/fake-google-map-utils.ts index f0747e883b29..5f5988efc736 100644 --- a/src/google-maps/testing/fake-google-map-utils.ts +++ b/src/google-maps/testing/fake-google-map-utils.ts @@ -14,6 +14,7 @@ export interface TestingWindow extends Window { maps: { Map?: jasmine.Spy; Marker?: jasmine.Spy; + InfoWindow?: jasmine.Spy; }; }; } @@ -74,3 +75,33 @@ export function createMarkerConstructorSpy(markerSpy: jasmine.SpyObj { + const infoWindowSpy = jasmine.createSpyObj( + 'google.maps.InfoWindow', + ['addListener', 'close', 'getContent', 'getPosition', 'getZIndex', 'open']); + infoWindowSpy.addListener.and.returnValue({remove: () => {}}); + return infoWindowSpy; +} + +/** Creates a jasmine.Spy to watch for the constructor of a google.maps.InfoWindow */ +export function createInfoWindowConstructorSpy( + infoWindowSpy: jasmine.SpyObj): jasmine.Spy { + const infoWindowConstructorSpy = + jasmine.createSpy('InfoWindow constructor', (_options: google.maps.InfoWindowOptions) => { + return infoWindowSpy; + }); + const testingWindow: TestingWindow = window; + if (testingWindow.google && testingWindow.google.maps) { + testingWindow.google.maps['InfoWindow'] = infoWindowConstructorSpy; + } else { + testingWindow.google = { + maps: { + 'InfoWindow': infoWindowConstructorSpy, + }, + }; + } + return infoWindowConstructorSpy; +} diff --git a/stylelint-config.json b/stylelint-config.json index 161526f6890e..7ad289801351 100644 --- a/stylelint-config.json +++ b/stylelint-config.json @@ -89,7 +89,7 @@ "no-eol-whitespace": true, "max-line-length": 100, "linebreaks": "unix", - "selector-class-pattern": ["^_?(mat-|cdk-|example-|demo-|ng-|mdc-)", { + "selector-class-pattern": ["^_?(mat-|cdk-|example-|demo-|ng-|mdc-|map-)", { "resolveNestedSelectors": true }] }