diff --git a/docs/api-reference/components/info-window.md b/docs/api-reference/components/info-window.md index 503778b..93198a9 100644 --- a/docs/api-reference/components/info-window.md +++ b/docs/api-reference/components/info-window.md @@ -5,22 +5,26 @@ are typically used to display additional bits of information for locations on the map – for example, to add a label or image to a marker. They can be freely positioned on the map, or they can be "anchored" to a marker. +Any JSX element added to the InfoWindow component as children will get +rendered into the content area of the InfoWindow. + When an `InfoWindow` is shown, the map will make sure to reposition the viewport -such that the InfoWindow is well visible within the map container. +such that the InfoWindow is well visible within the map container (this can +be controlled using the [`disableAutoPan`](#disableautopan-boolean) prop). InfoWindows always use the same well-known styling and are limited in how much -their look and feel can be customized. Any JSX element added to the -InfoWindow component as children will get rendered into the content of the -InfoWindow. +their look and feel can be customized. :::note -The rendered InfoWindow includes a close-button that can't be removed or -controlled via the Maps JavaScript API. This means that the application can't -fully control the visibility of the InfoWindow. +By default, the rendered InfoWindow includes a close-button that can't be +controlled via the Maps JavaScript API. There are also situations where the +map itself will close the InfoWindow (for example, when the InfoWindows +anchor is removed). This means that the application doesn't always have full +control over the visibility of the InfoWindow. -To keep your state in sync with the map, you have to provide a listener for the -`onClose` event so the application knows when then InfoWindow was closed by +To keep your state in sync with the map, you have to provide a listener for the +`onClose` event so the application knows when then InfoWindow was closed by the map or the user. ::: @@ -68,8 +72,8 @@ const MarkerWithInfoWindow = ({position}) => { const [infoWindowShown, setInfoWindowShown] = useState(false); // clicking the marker will toggle the infowindow - const handleMarkerClick = useCallback(() => - setInfoWindowShown(isShown => !isShown), + const handleMarkerClick = useCallback( + () => setInfoWindowShown(isShown => !isShown), [] ); @@ -121,12 +125,15 @@ When an `anchor` is specified, the `position` prop will be ignored. #### `anchor`: google.maps.Marker | google.maps.marker.AdvancedMarkerElement A Marker or AdvancedMarker instance to be used as an anchor. If specified, the -InfoWindow will be positioned at the top-center of the anchor. +InfoWindow will be positioned at the top-center of the anchor. References to +the Marker / AdvancedMarkerElement objects needed can be obtained using the +`ref` property of the `Marker` and `AdvancedMarker` components (see example +above). #### `zIndex`: number All InfoWindows are displayed on the map in order of their zIndex, with -higher values displaying in front of InfoWindows with lower values. By +higher values displayed in front of InfoWindows with lower values. By default, InfoWindows are displayed according to their latitude, with InfoWindows of lower latitudes appearing in front of InfoWindows at higher latitudes. InfoWindows are always displayed in front of markers. @@ -189,6 +196,43 @@ The `minWidth` can't be changed while the InfoWindow is open. ::: + +#### `headerContent`: string | React.ReactNode + +The content to display in the InfoWindow header row. +This can be any JSX element, or a string that could also contain HTML. When +a JSX Element is specified, the `headerContent` will be rendered to html via a +[portal][react-portal], which requires an additional div element to be added +as a container. + +```tsx +InfoWindow Header Content}> + This is the content of the InfoWindow. + +``` + +:::note + +This feature is currently only available in the beta channel of the Maps +JavaScript API. Set the `version` prop of your `APIProvider` to `beta` to +enable it. + +::: + +#### `headerDisabled`: boolean + +Disables the whole header row in the InfoWindow. When set to true, the +header will be removed so that the header content and the close button will +be hidden. + +:::note + +This feature is currently only available in the beta channel of the Maps +JavaScript API. Set the `version` prop of your `APIProvider` to `beta` to +enable it. + +::: + ### Events #### `onClose`: () => void @@ -205,3 +249,4 @@ This event is fired when the close button was clicked. [gmp-infowindow]: https://developers.google.com/maps/documentation/javascript/infowindows [gmp-infowindow-options]: https://developers.google.com/maps/documentation/javascript/reference/info-window#InfoWindowOptions [react-dev-styling]: https://react.dev/reference/react-dom/components/common#applying-css-styles +[react-portal]: https://react.dev/reference/react-dom/createPortal diff --git a/src/components/__tests__/info-window.test.tsx b/src/components/__tests__/info-window.test.tsx index 94d1c34..deee7c6 100644 --- a/src/components/__tests__/info-window.test.tsx +++ b/src/components/__tests__/info-window.test.tsx @@ -1,14 +1,12 @@ import React from 'react'; import {cleanup, queryByTestId, render} from '@testing-library/react'; -import {initialize} from '@googlemaps/jest-mocks'; +import {initialize, mockInstances} from '@googlemaps/jest-mocks'; import '@testing-library/jest-dom'; -import {InfoWindow as InfoWindowComponent} from '../info-window'; +import {InfoWindow} from '../info-window'; import {useMap} from '../../hooks/use-map'; import {useMapsLibrary} from '../../hooks/use-maps-library'; -import {waitForMockInstance} from './__utils__/wait-for-mock-instance'; - jest.mock('../../hooks/use-map'); jest.mock('../../hooks/use-maps-library'); @@ -16,130 +14,302 @@ let createInfowindowSpy: jest.Mock; let useMapMock: jest.MockedFn; let useMapsLibraryMock: jest.MockedFn; +let mapsLib: google.maps.MapsLibrary; +let map: google.maps.Map; + beforeEach(async () => { initialize(); jest.clearAllMocks(); + mapsLib = (await google.maps.importLibrary( + 'maps' + )) as google.maps.MapsLibrary; + map = new google.maps.Map(document.createElement('div')); + useMapMock = jest.mocked(useMap); useMapsLibraryMock = jest.mocked(useMapsLibrary); - createInfowindowSpy = jest.fn().mockName('InfoWindow.constructor'); - google.maps.InfoWindow = class extends google.maps.InfoWindow { + useMapMock.mockReturnValue(map); + useMapsLibraryMock.mockImplementation(name => { + expect(name).toEqual('maps'); + return mapsLib; + }); + + createInfowindowSpy = jest.fn().mockName('InfoWindow.new'); + const InfoWindowImpl = class extends google.maps.InfoWindow { constructor(o?: google.maps.InfoWindowOptions) { createInfowindowSpy(o); super(o); } }; + google.maps.InfoWindow = mapsLib.InfoWindow = InfoWindowImpl; }); afterEach(() => { cleanup(); }); -test('InfoWindow creates a InfoWindow instance and passes options', async () => { - useMapsLibraryMock.mockReturnValue(null); - useMapMock.mockReturnValue(null); - - const {rerender} = render( - - ); - - expect(useMapsLibraryMock).toHaveBeenCalledWith('maps'); - expect(createInfowindowSpy).not.toHaveBeenCalled(); - - const mapsLib = google.maps; - useMapsLibraryMock.mockReturnValue(mapsLib); - - rerender( - - ); - - expect(createInfowindowSpy).toHaveBeenCalled(); - - const [actualOptions] = createInfowindowSpy.mock.calls[0]; - expect(actualOptions).toMatchObject({ - ariaLabel: 'ariaLabel', - position: {lat: 1, lng: 2}, - minWidth: 200, - maxWidth: 300, - zIndex: 99, - disableAutoPan: true +describe(' basic functionality', () => { + test('Infowindow is created once mapsLibrary is ready', async () => { + useMapsLibraryMock.mockReturnValue(null); + useMapMock.mockReturnValue(null); + + const {rerender} = render(); + + expect(useMapsLibraryMock).toHaveBeenCalledWith('maps'); + expect(createInfowindowSpy).not.toHaveBeenCalled(); + + // now return the mapsLib so the infowindow can be created + useMapsLibraryMock.mockReturnValue(mapsLib); + rerender(); + + expect(createInfowindowSpy).toHaveBeenCalled(); + // infoWindow.open will only be called once the map is available + const [iw] = mockInstances.get(google.maps.InfoWindow); + expect(iw.open).not.toHaveBeenCalled(); + + // rerendering with map present preserves the infowindow instance and opens it + useMapMock.mockReturnValue(map); + rerender(); + + expect(iw.open).toHaveBeenCalled(); + }); + + test('props get forwarded to constructor on initial creation', async () => { + render( + + ); + + expect(createInfowindowSpy).toHaveBeenCalled(); + const [actualOptions] = createInfowindowSpy.mock.lastCall; + expect(actualOptions).toMatchObject({ + ariaLabel: 'ariaLabel', + position: {lat: 1, lng: 2}, + minWidth: 200, + maxWidth: 300, + zIndex: 99, + disableAutoPan: true, + headerDisabled: true + }); + + expect(actualOptions.pixelOffset).toBeInstanceOf(google.maps.Size); + expect(actualOptions.pixelOffset.width).toBe(5); + expect(actualOptions.pixelOffset.height).toBe(6); + }); + + test('changing options get passed to setOptions()', async () => { + const position = {lat: 1, lng: 2}; + const {rerender} = render(); + + expect(createInfowindowSpy).toHaveBeenCalled(); + const [initialOptions] = createInfowindowSpy.mock.lastCall; + expect(initialOptions).toEqual({position: {lat: 1, lng: 2}}); + + rerender( + + ); + + const [iw] = jest.mocked(mockInstances.get(google.maps.InfoWindow)); + + expect(iw.setOptions).toHaveBeenCalled(); + const [updatedOptions] = iw.setOptions.mock.lastCall as [unknown]; + expect(updatedOptions).toMatchObject({ + minWidth: 200, + maxWidth: 300, + position: {lat: 1, lng: 2} + }); + }); + + test('props get forwarded to openOptions', async () => { + const marker = new google.maps.marker.AdvancedMarkerElement(); + + render(); + + const [iw] = jest.mocked(mockInstances.get(google.maps.InfoWindow)); + expect(iw.open).toHaveBeenCalled(); + + const [openOptions] = iw.open.mock.lastCall as [unknown]; + + expect(openOptions).toEqual({anchor: marker, map, shouldFocus: false}); + }); +}); + +describe(' content rendering', () => { + test('InfoWindow should render content into portal node', async () => { + render( + + Hello World! + + ); + + expect(createInfowindowSpy).toHaveBeenCalled(); + const [infoWindow] = jest.mocked(mockInstances.get(google.maps.InfoWindow)); + + // assert setContent was called with the content element + expect(infoWindow.setContent).toHaveBeenCalledTimes(1); + const [contentEl] = infoWindow.setContent.mock.lastCall as [HTMLElement]; + expect(contentEl).toBeInstanceOf(HTMLElement); + + // style and className should be applied to the content element + expect(contentEl).toHaveClass('infowindow-content'); + expect(contentEl).toHaveStyle({backgroundColor: 'red', padding: '8px'}); + + // child nodes should have been rendered into contentEl + expect(queryByTestId(contentEl, 'content')).toHaveTextContent( + 'Hello World!' + ); + }); +}); + +describe(' headerContent rendering', () => { + test('passes headerContent to options when its a string', async () => { + render(); + + expect(createInfowindowSpy).toHaveBeenCalled(); + const [options] = createInfowindowSpy.mock.lastCall; + expect(options).toEqual({headerContent: 'Infowindow Header'}); }); - expect(actualOptions.pixelOffset).toBeInstanceOf(google.maps.Size); - expect(actualOptions.pixelOffset.width).toBe(5); - expect(actualOptions.pixelOffset.height).toBe(6); + test('creates a dom-element when passing a ReactNode', async () => { + render(Infowindow Header} />); + + expect(createInfowindowSpy).toHaveBeenCalled(); + const [options] = createInfowindowSpy.mock.lastCall; + expect(options.headerContent).toBeInstanceOf(HTMLElement); + expect(options.headerContent).toContainHTML('

Infowindow Header

'); + }); + + test('updates html-content when content props changes', async () => { + const {rerender} = render( + Infowindow Header}> + ); + + rerender( + New Infowindow Header}> + ); + const [iw] = jest.mocked(mockInstances.get(google.maps.InfoWindow)); + + expect(iw.setOptions).toHaveBeenCalled(); + const [updatedOptions] = iw.setOptions.mock.lastCall as [ + google.maps.InfoWindowOptions + ]; + expect(updatedOptions.headerContent).toBeInstanceOf(HTMLElement); + expect(updatedOptions.headerContent).toContainHTML( + '

New Infowindow Header

' + ); + }); + + test('changes from text- to html-content', async () => { + const {rerender} = render(); + + rerender( + New Infowindow Header}> + ); + const [iw] = jest.mocked(mockInstances.get(google.maps.InfoWindow)); + + expect(iw.setOptions).toHaveBeenCalled(); + const [updatedOptions] = iw.setOptions.mock.lastCall as [ + google.maps.InfoWindowOptions + ]; + expect(updatedOptions.headerContent).toBeInstanceOf(HTMLElement); + expect(updatedOptions.headerContent).toContainHTML( + '

New Infowindow Header

' + ); + }); + + test('changes from html-content to no content', async () => { + const {rerender} = render( + New Infowindow Header}> + ); + + rerender(); - const infoWindow = jest.mocked( - await waitForMockInstance(google.maps.InfoWindow) - ); + const [iw] = jest.mocked(mockInstances.get(google.maps.InfoWindow)); - expect(infoWindow).toBeDefined(); - expect(infoWindow.setOptions).not.toHaveBeenCalled(); + expect(iw.setOptions).toHaveBeenCalled(); + const [updatedOptions] = iw.setOptions.mock.lastCall as [ + google.maps.InfoWindowOptions + ]; + expect(updatedOptions.headerContent).toBeNull(); + }); }); -test('InfoWindow should render content into detached node', async () => { - const mapsLib = google.maps; - useMapsLibraryMock.mockReturnValue(mapsLib); - - const mockMap = new google.maps.Map(document.createElement('div')); - useMapMock.mockReturnValue(mockMap); - - const marker = new google.maps.marker.AdvancedMarkerElement(); - - render( - - Hello World! - - ); - - const infoWindow = jest.mocked( - await waitForMockInstance(google.maps.InfoWindow) - ); - - // assert .open() was called with correct options - expect(infoWindow.open).toHaveBeenCalled(); - const [openOptions] = infoWindow.open.mock.calls[0] as [ - google.maps.InfoWindowOpenOptions - ]; - expect(openOptions.map).toBe(mockMap); - expect(openOptions.anchor).toBe(marker); - expect(openOptions.shouldFocus).toBe(false); - - // assert setContent was called with the content element - expect(infoWindow.setContent).toHaveBeenCalledTimes(1); - const [contentEl] = infoWindow.setContent.mock.calls[0] as [HTMLElement]; - expect(contentEl).toBeInstanceOf(HTMLElement); - - // style and className should be applied to the content element - expect(contentEl).toHaveClass('infowindow-content'); - expect(contentEl).toHaveStyle({backgroundColor: 'red', padding: '8px'}); - - // child nodes should have been rendered - expect(queryByTestId(contentEl, 'content')).toBeTruthy(); - expect(queryByTestId(contentEl, 'content')).toHaveTextContent('Hello World!'); - - expect(infoWindow.open).toHaveBeenCalled(); +describe(' cleanup', () => { + test('infowindow is closed when unmounted', () => { + const marker = new google.maps.marker.AdvancedMarkerElement(); + const {rerender} = render( + <> + + + ); + + expect(createInfowindowSpy).toHaveBeenCalled(); + const [infoWindow] = jest.mocked(mockInstances.get(google.maps.InfoWindow)); + expect(infoWindow.open).toHaveBeenCalled(); + + rerender(<>); + + expect(infoWindow.close).toHaveBeenCalled(); + }); }); -test.todo('InfoWindow updates options and content correctly'); -test.todo('InfoWindow cleanup'); -test.todo('InfoWindow events'); +describe(' events', () => { + test('triggers onClose and onCloseClick handlers on event', async () => { + const onCloseSpy = jest.fn(); + const onCloseClickSpy = jest.fn(); + + const gme = jest.mocked(google.maps.event); + gme.addListener.mockImplementation(() => ({remove: jest.fn()})); + + render(); + + expect(createInfowindowSpy).toHaveBeenCalled(); + const [infoWindow] = jest.mocked(mockInstances.get(google.maps.InfoWindow)); + + expect(gme.addListener).toHaveBeenCalledWith( + infoWindow, + 'close', + onCloseSpy + ); + expect(gme.addListener).toHaveBeenCalledWith( + infoWindow, + 'closeclick', + onCloseClickSpy + ); + }); + + test('removes handlers on unmount', async () => { + const listeners = { + close: {remove: jest.fn()}, + closeclick: {remove: jest.fn()} + }; + const gme = jest.mocked(google.maps.event); + gme.addListener.mockImplementation( + (_, name) => listeners[name as keyof typeof listeners] + ); + + const {rerender} = render( + <> + + + ); + + rerender(<>); + + expect(listeners.close.remove).toHaveBeenCalled(); + expect(listeners.closeclick.remove).toHaveBeenCalled(); + }); +}); diff --git a/src/components/info-window.tsx b/src/components/info-window.tsx index c1306bf..926935a 100644 --- a/src/components/info-window.tsx +++ b/src/components/info-window.tsx @@ -1,7 +1,9 @@ /* eslint-disable complexity */ import React, { + ComponentType, CSSProperties, PropsWithChildren, + ReactNode, useEffect, useRef, useState @@ -16,7 +18,7 @@ import {useDeepCompareEffect} from '../libraries/use-deep-compare-effect'; export type InfoWindowProps = Omit< google.maps.InfoWindowOptions, - 'content' | 'pixelOffset' + 'headerContent' | 'content' | 'pixelOffset' > & { style?: CSSProperties; className?: string; @@ -25,6 +27,8 @@ export type InfoWindowProps = Omit< shouldFocus?: boolean; onClose?: () => void; onCloseClick?: () => void; + + headerContent?: ReactNode; }; /** @@ -34,6 +38,8 @@ export const InfoWindow = (props: PropsWithChildren) => { const { // content options children, + headerContent, + style, className, pixelOffset, @@ -41,10 +47,12 @@ export const InfoWindow = (props: PropsWithChildren) => { // open options anchor, shouldFocus, + // events onClose, onCloseClick, + // other options ...infoWindowOptions } = props; @@ -53,37 +61,51 @@ export const InfoWindow = (props: PropsWithChildren) => { const [infoWindow, setInfoWindow] = useState( null ); - const [contentContainer, setContentContainer] = useState( - null - ); + + const contentContainerRef = useRef(null); + const headerContainerRef = useRef(null); useEffect( () => { if (!mapsLibrary) return; + contentContainerRef.current = document.createElement('div'); + headerContainerRef.current = document.createElement('div'); + + const opts: google.maps.InfoWindowOptions = infoWindowOptions; if (pixelOffset) { - (infoWindowOptions as google.maps.InfoWindowOptions).pixelOffset = - new google.maps.Size(pixelOffset[0], pixelOffset[1]); + opts.pixelOffset = new google.maps.Size(pixelOffset[0], pixelOffset[1]); + } + + if (headerContent) { + // if headerContent is specified as string we can directly forward it, + // otherwise we'll pass the element the portal will render into + opts.headerContent = + typeof headerContent === 'string' + ? headerContent + : headerContainerRef.current; } // intentionally shadowing the state variables here const infoWindow = new google.maps.InfoWindow(infoWindowOptions); - const contentContainer = document.createElement('div'); - infoWindow.setContent(contentContainer); + infoWindow.setContent(contentContainerRef.current); setInfoWindow(infoWindow); - setContentContainer(contentContainer); - // unmount: remove infoWindow and contentElement + // unmount: remove infoWindow and content elements (note: close is called in a different effect-cleanup) return () => { infoWindow.setContent(null); - contentContainer.remove(); + + contentContainerRef.current?.remove(); + headerContainerRef.current?.remove(); + + contentContainerRef.current = null; + headerContainerRef.current = null; setInfoWindow(null); - setContentContainer(null); }; }, - // `infoWindowOptions` and `pixelOffset` are missing from dependencies: + // `infoWindowOptions` and other props are missing from dependencies: // // We don't want to re-create the infowindow instance // when the options change. @@ -97,23 +119,39 @@ export const InfoWindow = (props: PropsWithChildren) => { // stores previously applied style properties, so they can be removed when unset const prevStyleRef = useRef(null); useEffect(() => { - if (!contentContainer) return; + if (!infoWindow || !contentContainerRef.current) return; + + setValueForStyles( + contentContainerRef.current, + style || null, + prevStyleRef.current + ); - setValueForStyles(contentContainer, style || null, prevStyleRef.current); prevStyleRef.current = style || null; - if (className !== contentContainer.className) - contentContainer.className = className || ''; - }, [contentContainer, className, style]); + if (className !== contentContainerRef.current.className) + contentContainerRef.current.className = className || ''; + }, [infoWindow, className, style]); // ## update options useDeepCompareEffect( () => { if (!infoWindow) return; - if (pixelOffset) { - (infoWindowOptions as google.maps.InfoWindowOptions).pixelOffset = - new google.maps.Size(pixelOffset[0], pixelOffset[1]); + const opts: google.maps.InfoWindowOptions = infoWindowOptions; + if (!pixelOffset) { + opts.pixelOffset = null; + } else { + opts.pixelOffset = new google.maps.Size(pixelOffset[0], pixelOffset[1]); + } + + if (!headerContent) { + opts.headerContent = null; + } else { + opts.headerContent = + typeof headerContent === 'string' + ? headerContent + : headerContainerRef.current; } infoWindow.setOptions(infoWindowOptions); @@ -122,7 +160,7 @@ export const InfoWindow = (props: PropsWithChildren) => { // dependency `infoWindow` isn't needed since options are also passed // to the constructor when a new infoWindow is created. // eslint-disable-next-line react-hooks/exhaustive-deps - [infoWindowOptions] + [infoWindowOptions, pixelOffset, headerContent] ); // ## bind event handlers @@ -133,7 +171,7 @@ export const InfoWindow = (props: PropsWithChildren) => { const map = useMap(); useEffect(() => { // `anchor === null` means an anchor is defined but not ready yet. - if (!contentContainer || !infoWindow || anchor === null) return; + if (!map || !infoWindow || anchor === null) return; const isOpenedWithAnchor = !!anchor; const openOptions: google.maps.InfoWindowOpenOptions = {map}; @@ -156,9 +194,15 @@ export const InfoWindow = (props: PropsWithChildren) => { infoWindow.close(); }; - }, [infoWindow, contentContainer, anchor, map, shouldFocus]); + }, [infoWindow, anchor, map, shouldFocus]); return ( - <>{contentContainer !== null && createPortal(children, contentContainer)} + <> + {contentContainerRef.current && + createPortal(children, contentContainerRef.current)} + + {headerContainerRef.current !== null && + createPortal(headerContent, headerContainerRef.current)} + ); };