diff --git a/docs/api-reference/components/popover-3d.md b/docs/api-reference/components/popover.md similarity index 71% rename from docs/api-reference/components/popover-3d.md rename to docs/api-reference/components/popover.md index b4a8e6f1..191ea7aa 100644 --- a/docs/api-reference/components/popover-3d.md +++ b/docs/api-reference/components/popover.md @@ -1,16 +1,16 @@ -# `` Component +# `` Component A component to add a [`PopoverElement`][gmp-popover] to a 3D map. Popovers are overlay elements similar to InfoWindows for 2D maps, used to display contextual information at a specific location or anchored to a marker on a Map3D. -Any JSX element added to the Popover3D component as children will get +Any JSX element added to the Popover component as children will get rendered into the content area of the popover. ## Usage -The `Popover3D` component must be used within an [``](./api-provider.md) +The `Popover` component must be used within an [``](./api-provider.md) that also contains a [``](./map-3d.md) component. ### Basic Example @@ -18,7 +18,7 @@ that also contains a [``](./map-3d.md) component. Display a popover at a specific position on the 3D map: ```tsx -import {APIProvider, Map3D, Popover3D} from '@vis.gl/react-google-maps'; +import {APIProvider, Map3D, Popover} from '@vis.gl/react-google-maps'; const App = () => { const [isOpen, setIsOpen] = useState(true); @@ -29,7 +29,7 @@ const App = () => { defaultCenter={{lat: 37.7749, lng: -122.4194, altitude: 500}} defaultRange={2000} defaultTilt={60}> - setIsOpen(false)}> @@ -37,7 +37,7 @@ const App = () => {

San Francisco

Welcome to the city by the bay!

-
+
); @@ -50,12 +50,7 @@ A more typical use-case is to have a popover shown when clicking on a marker. You can anchor the popover to a `Marker3DInteractiveElement` using the `anchor` prop: ```tsx -import { - APIProvider, - Map3D, - Marker3D, - Popover3D -} from '@vis.gl/react-google-maps'; +import {APIProvider, Map3D, Marker3D, Popover} from '@vis.gl/react-google-maps'; const MarkerWithPopover = ({position}) => { const [markerElement, setMarkerElement] = useState(null); @@ -71,7 +66,7 @@ const MarkerWithPopover = ({position}) => { /> {markerElement && ( - setPopoverOpen(false)}> @@ -79,27 +74,38 @@ const MarkerWithPopover = ({position}) => {

Location Info

This popover is anchored to the marker.

-
+ )} ); }; ``` +### Content and Header + +The content provided as children to the Popover component is rendered into +the main content area of the popover. +Additionally, the `headerContent` prop can be used to render DOM elements +into the separate header slot of the `gmp-popover` web component. + ### Light Dismiss Behavior By default, popovers can be closed by clicking outside of them ("light dismiss"). You can disable this behavior with the `lightDismissDisabled` prop: ```tsx -
This popover won't close when clicking outside
-
+ ``` +To track the current visibility of the popover, you also have to add an +`onClose` callback. This will get called when the popover is automatically +closed by the maps API. + :::note When `lightDismissDisabled` is true, you must provide another way for users @@ -107,24 +113,42 @@ to close the popover, such as a close button inside the content. ::: +### Automatic Panning + +By default, the map pans to make sure the popover is fully visible when it +opens. This can cause conflicts when you want to have a fully controlled map +state. In this case, this can be disabled using the `autoPanDisabled` prop. + +```tsx +import {Popover, AltitudeMode} from '@vis.gl/react-google-maps'; + + +
Popover content!
+
; +``` + ### Popover with Altitude Position a popover at a specific altitude above the ground: ```tsx -import {Popover3D, AltitudeMode} from '@vis.gl/react-google-maps'; +import {Popover, AltitudeMode} from '@vis.gl/react-google-maps'; -
Floating 100m above ground!
-
; +; ``` ## Props -The `Popover3DProps` type extends [`google.maps.maps3d.PopoverElementOptions`][gmp-popover-options] +The `PopoverProps` type extends [`google.maps.maps3d.PopoverElementOptions`][gmp-popover-options] with additional React-specific props. ### Required @@ -140,14 +164,14 @@ The position at which to display this popover. Can include an optional `altitude ```tsx // 2D position - + Content here - + // 3D position with altitude - + Content here - + ``` :::note @@ -168,9 +192,9 @@ the popover will be positioned relative to the marker. onClick={() => setOpen(true)} /> - + Anchored content - + ``` #### `anchorId`: string @@ -183,14 +207,14 @@ This is an alternative to using the `anchor` prop when you have the marker's ID. Specifies how the altitude component of the position is interpreted. ```tsx -import {Popover3D, AltitudeMode} from '@vis.gl/react-google-maps'; +import {Popover, AltitudeMode} from '@vis.gl/react-google-maps'; - Content here -; +; ``` Available values: @@ -209,9 +233,9 @@ Whether the popover is currently visible. Defaults to `false`. ```tsx const [isOpen, setIsOpen] = useState(false); - + Content here -; +; ``` #### `lightDismissDisabled`: boolean @@ -220,9 +244,9 @@ When `true`, prevents the popover from being closed when clicking outside of it. Defaults to `false`. ```tsx - + This popover won't close on outside click - + ``` ### Events @@ -235,9 +259,9 @@ Use this to keep your state in sync with the popover's visibility. ```tsx const [isOpen, setIsOpen] = useState(true); - setIsOpen(false)}> + setIsOpen(false)}> Content here -; +; ``` :::note @@ -249,12 +273,12 @@ It will not fire when you programmatically set `open={false}`. ## Ref -The Popover3D component supports a ref that exposes the underlying +The Popover component supports a ref that exposes the underlying `google.maps.maps3d.PopoverElement` instance: ```tsx import {useRef} from 'react'; -import {Popover3D} from '@vis.gl/react-google-maps'; +import {Popover} from '@vis.gl/react-google-maps'; const MyComponent = () => { const popoverRef = useRef(null); @@ -266,9 +290,9 @@ const MyComponent = () => { }; return ( - + Content here - + ); }; ``` diff --git a/docs/table-of-contents.json b/docs/table-of-contents.json index 3f2db283..38959cbf 100644 --- a/docs/table-of-contents.json +++ b/docs/table-of-contents.json @@ -44,7 +44,7 @@ "api-reference/components/static-map", "api-reference/components/map-3d", "api-reference/components/marker-3d", - "api-reference/components/popover-3d" + "api-reference/components/popover" ] }, { diff --git a/examples/map-3d-markers/README.md b/examples/map-3d-markers/README.md new file mode 100644 index 00000000..6fc01be8 --- /dev/null +++ b/examples/map-3d-markers/README.md @@ -0,0 +1,43 @@ +# 3D Maps with Markers Example + +This example demonstrates the `Map3D`, `Marker3D`, and `Pin` components for +rendering 3D maps based on the [Google Maps 3D][gmp-map3d-overview] web components. + +The map showcases various marker types including basic markers, extruded markers, +markers with custom pins, SVG/image markers, and 3D models. Learn more about +[adding markers to 3D maps][gmp-map3d-marker-add]. + +[gmp-map3d-overview]: https://developers.google.com/maps/documentation/javascript/3d-maps-overview +[gmp-map3d-marker-add]: https://developers.google.com/maps/documentation/javascript/3d/marker-add + +## Google Maps API key + +This example does not come with an API key. Running the examples locally requires a valid API key for the Google Maps Platform. +See [the official documentation][get-api-key] on how to create and configure your own key. + +The API key has to be provided via an environment variable `GOOGLE_MAPS_API_KEY`. This can be done by creating a +file named `.env` in the example directory with the following content: + +```shell title=".env" +GOOGLE_MAPS_API_KEY="" +``` + +If you are on the CodeSandbox playground you can also choose to [provide the API key like this](https://codesandbox.io/docs/learn/environment/secrets) + +## Development + +Go into the example-directory and run + +```shell +npm install +``` + +To start the example with the local library run + +```shell +npm run start-local +``` + +The regular `npm start` task is only used for the standalone versions of the example (CodeSandbox for example) + +[get-api-key]: https://developers.google.com/maps/documentation/javascript/get-api-key diff --git a/examples/map-3d-markers/data/balloon-red.glb b/examples/map-3d-markers/data/balloon-red.glb new file mode 100644 index 00000000..9d39850b Binary files /dev/null and b/examples/map-3d-markers/data/balloon-red.glb differ diff --git a/examples/map-3d-markers/index.html b/examples/map-3d-markers/index.html new file mode 100644 index 00000000..a9c7fcfd --- /dev/null +++ b/examples/map-3d-markers/index.html @@ -0,0 +1,31 @@ + + + + + + Example: Photorealistic 3D Map with Markers + + + + +
+ + + diff --git a/examples/map-3d-markers/package.json b/examples/map-3d-markers/package.json new file mode 100644 index 00000000..1acd7690 --- /dev/null +++ b/examples/map-3d-markers/package.json @@ -0,0 +1,15 @@ +{ + "type": "module", + "dependencies": { + "@vis.gl/react-google-maps": "^1.8.0-rc.8", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.4.5", + "vite": "^6.0.11" + }, + "scripts": { + "start": "vite", + "start-local": "vite --config ../vite.config.local.js", + "build": "vite build" + } +} diff --git a/examples/map-3d-markers/src/app.tsx b/examples/map-3d-markers/src/app.tsx new file mode 100644 index 00000000..ec17edda --- /dev/null +++ b/examples/map-3d-markers/src/app.tsx @@ -0,0 +1,176 @@ +/* eslint-disable no-console */ +import React, {useEffect, useState} from 'react'; +import {createRoot} from 'react-dom/client'; + +import { + AltitudeMode, + APIProvider, + Map3D, + MapMode, + Marker3D, + Pin, + Popover +} from '@vis.gl/react-google-maps'; + +import {Model3D, Model3DProps} from './model-3d'; +import ControlPanel from './control-panel'; + +const API_KEY = + globalThis.GOOGLE_MAPS_API_KEY ?? (process.env.GOOGLE_MAPS_API_KEY as string); + +const INITIAL_VIEW_PROPS = { + defaultCenter: {lat: 40.7093, lng: -73.9968, altitude: 32}, + defaultRange: 1733, + defaultHeading: 5, + defaultTilt: 70, + defaultRoll: 0 +}; + +/** + * AnimatedModel3D wraps the Model3D component with rotation animation. + * Demonstrates how to animate 3D models using requestAnimationFrame. + */ +const AnimatedModel3D = (modelProps: Model3DProps) => { + const rotationSpeed = 10; + const [modelHeading, setModelHeading] = useState(0); + + // Animate the model rotation using requestAnimationFrame + useEffect(() => { + let animationFrameId: number; + let lastTimestamp = 0; + + const animate = (timestamp: number) => { + if (lastTimestamp === 0) lastTimestamp = timestamp; + + const deltaTime = (timestamp - lastTimestamp) / 1000; // Convert to seconds + lastTimestamp = timestamp; + + setModelHeading(prev => (prev + rotationSpeed * deltaTime) % 360); + + animationFrameId = requestAnimationFrame(animate); + }; + + animationFrameId = requestAnimationFrame(animate); + + return () => cancelAnimationFrame(animationFrameId); + }, [rotationSpeed]); + + return ( + + ); +}; + +const App = () => { + const [openPopoverId, setOpenPopoverId] = useState(null); + const [interactiveMarker, setInteractiveMarker] = + useState(null); + + return ( + + + {/* Basic marker with popover (non-interactive) */} + + + {/* Basic extruded marker */} + + + {/* Interactive marker with colored pin and popover */} + { + console.log('Interactive marker clicked!'); + setOpenPopoverId('colored-pin'); + }} + title="Click to see details"> + + + + {openPopoverId === 'colored-pin' && ( + { + setOpenPopoverId(null); + console.log('close'); + }}> +
+

+ Custom Pin Marker +

+

+ An interactive marker with custom pin colors. Click the marker + to toggle this popover. +

+
+
+ )} + + {/* Marker with customized pin */} + + + + + {/* Marker with SVG image */} + + + + + {/* Animated 3D Model */} + +
+ + +
+ ); +}; +export default App; + +export function renderToDom(container: HTMLElement) { + const root = createRoot(container); + + root.render( + + + + ); +} diff --git a/examples/map-3d-markers/src/control-panel.tsx b/examples/map-3d-markers/src/control-panel.tsx new file mode 100644 index 00000000..2e27918e --- /dev/null +++ b/examples/map-3d-markers/src/control-panel.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; + +const GMP_3D_MAPS_OVERVIEW_URL = + 'https://developers.google.com/maps/documentation/javascript/3d-maps-overview'; + +const GMP_3D_MAPS_MARKER_ADD_URL = + 'https://developers.google.com/maps/documentation/javascript/3d/marker-add'; + +function ControlPanel() { + return ( +
+

3D Maps with Markers

+

+ This example demonstrates the Map3D, Marker3D, + and Pin components for rendering 3D maps based on the{' '} + + Google Maps 3D + {' '} + web components. +

+ +

+ The map showcases various marker types including basic markers, extruded + markers, markers with custom pins, SVG/image markers, and 3D models. + Learn more about{' '} + + adding markers to 3D maps + + . +

+ + +
+ ); +} + +export default React.memo(ControlPanel); diff --git a/examples/map-3d-markers/src/model-3d.tsx b/examples/map-3d-markers/src/model-3d.tsx new file mode 100644 index 00000000..bea27584 --- /dev/null +++ b/examples/map-3d-markers/src/model-3d.tsx @@ -0,0 +1,42 @@ +import type {PropsWithChildren, Ref} from 'react'; +import React, {forwardRef} from 'react'; + +/** + * Props for the Model3D component. + */ +export type Model3DProps = + PropsWithChildren; + +/** + * Model3D component for displaying 3D models on a Map3D. + * + * @example + * ```tsx + * + * ``` + */ +export const Model3D = forwardRef(function Model3D( + props: Model3DProps, + ref: Ref +) { + const {position, altitudeMode, src, orientation, scale} = props; + + return ( + + ); +}); + +Model3D.displayName = 'Model3D'; diff --git a/examples/map-3d-markers/tsconfig.json b/examples/map-3d-markers/tsconfig.json new file mode 100644 index 00000000..ac61102d --- /dev/null +++ b/examples/map-3d-markers/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "typeRoots": ["./types", "../../types", "./node_modules/@types"], + "strict": true, + "sourceMap": true, + "noEmit": true, + "noImplicitAny": true, + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "target": "ES2020", + "lib": ["es2020", "dom"], + "jsx": "react", + "skipLibCheck": true + }, + "exclude": ["./dist", "./node_modules"], + "include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*.ts"] +} diff --git a/examples/map-3d-markers/types/global.d.ts b/examples/map-3d-markers/types/global.d.ts new file mode 100644 index 00000000..b65b2bbc --- /dev/null +++ b/examples/map-3d-markers/types/global.d.ts @@ -0,0 +1,7 @@ +export declare global { + // const or let does not work in this case, it has to be var + // eslint-disable-next-line no-var + var GOOGLE_MAPS_API_KEY: string | undefined; + // eslint-disable-next-line no-var, @typescript-eslint/no-explicit-any + var process: any; +} diff --git a/examples/map-3d-markers/types/google.maps.d.ts b/examples/map-3d-markers/types/google.maps.d.ts new file mode 100644 index 00000000..9335d8bc --- /dev/null +++ b/examples/map-3d-markers/types/google.maps.d.ts @@ -0,0 +1,766 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ +declare namespace google.maps { + /** + * Namespace for 3D Maps functionality. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map + */ + namespace maps3d { + /** + * Specifies a mode the map should be rendered in. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#MapMode + */ + enum MapMode { + /** This map mode displays a transparent layer of major streets on satellite imagery. */ + HYBRID = 'HYBRID', + /** This map mode displays satellite or photorealistic imagery. */ + SATELLITE = 'SATELLITE' + } + + /** + * Specifies how gesture events should be handled on the map element. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#GestureHandling + */ + enum GestureHandling { + /** + * This lets the map choose whether to use cooperative or greedy gesture handling. + * This is the default behavior if not specified. + * This will cause the map to enter cooperative mode if the map is dominating its + * scroll parent (usually the host page) to where the user cannot scroll away from + * the map to other content. + */ + AUTO = 'AUTO', + /** + * This forces cooperative mode, where modifier keys or two-finger gestures + * are required to scroll the map. + */ + COOPERATIVE = 'COOPERATIVE', + /** + * This forces greedy mode, where the host page cannot be scrolled from user + * events on the map element. + */ + GREEDY = 'GREEDY' + } + + /** + * Customization options for the FlyCameraAround Animation. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#FlyAroundAnimationOptions + */ + interface FlyAroundAnimationOptions { + /** + * The central point at which the camera should look at during the orbit animation. + */ + camera: CameraOptions; + /** + * The duration of one animation cycle in milliseconds. + */ + durationMillis?: number; + /** + * Specifies the number of times an animation should repeat. + * If the number is zero, the animation will complete immediately after it starts. + */ + repeatCount?: number; + /** + * @deprecated Please use repeatCount instead. + */ + rounds?: number; + } + + /** + * Customization options for the FlyCameraTo Animation. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#FlyToAnimationOptions + */ + interface FlyToAnimationOptions { + /** + * The location at which the camera should point at the end of the animation. + */ + endCamera: CameraOptions; + /** + * The duration of the animation in milliseconds. + * A duration of 0 will teleport the camera straight to the end position. + */ + durationMillis?: number; + } + + /** + * CameraOptions object used to define the properties that can be set on a camera object. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#CameraOptions + */ + interface CameraOptions { + center?: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + heading?: number; + range?: number; + roll?: number; + tilt?: number; + } + + /** + * This event is created from monitoring a steady state of Map3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#SteadyChangeEvent + */ + class SteadyChangeEvent extends Event { + /** + * Indicates whether Map3DElement is steady (i.e. all rendering for the current scene has completed) or not. + */ + isSteady: boolean; + } + + /** + * This event is created from clicking a Map3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#LocationClickEvent + */ + class LocationClickEvent extends Event { + /** + * The latitude/longitude/altitude that was below the cursor when the event occurred. + */ + position?: google.maps.LatLngAltitude; + } + + /** + * This event is created from clicking on a place icon on a Map3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#PlaceClickEvent + */ + class PlaceClickEvent extends LocationClickEvent { + /** + * The place id of the map feature. + */ + placeId: string; + + /** + * Fetches a Place for this place id. + */ + fetchPlace(): Promise; + } + + /** + * Shows a position on a 3D map. + * Note that the position must be set for the Marker3DElement to display. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Marker3DElement + */ + class Marker3DElement + extends HTMLElement + implements Marker3DElementOptions + { + constructor(options?: Marker3DElementOptions); + + /** + * Specifies how the altitude component of the position is interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * An enumeration specifying how a Marker3DElement should behave when it collides with another Marker3DElement or with the basemap labels. + * @default CollisionBehavior.REQUIRED + */ + collisionBehavior?: google.maps.marker.CollisionBehavior; + + /** + * Specifies whether this marker should be drawn or not when it's occluded. + * @default false + */ + drawsWhenOccluded?: boolean; + + /** + * Specifies whether to connect the marker to the ground. + * @default false + */ + extruded?: boolean; + + /** + * Text to be displayed by this marker. + */ + label?: string; + + /** + * The location of the tip of the marker. + */ + position?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral; + + /** + * Specifies whether this marker should preserve its size or not regardless of distance from camera. + * @default false + */ + sizePreserved?: boolean; + + /** + * The zIndex compared to other markers. + */ + zIndex?: number; + + addEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + /** + * Marker3DElementOptions object used to define the properties that can be set on a Marker3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Marker3DElementOptions + */ + interface Marker3DElementOptions { + altitudeMode?: AltitudeMode; + collisionBehavior?: google.maps.marker.CollisionBehavior; + drawsWhenOccluded?: boolean; + extruded?: boolean; + label?: string; + position?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral; + sizePreserved?: boolean; + zIndex?: number; + } + + /** + * Shows a position on a 3D map with interactive capabilities. + * Unlike Marker3DElement, Marker3DInteractiveElement receives a gmp-click event. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Marker3DInteractiveElement + */ + class Marker3DInteractiveElement + extends Marker3DElement + implements Marker3DInteractiveElementOptions + { + constructor(options?: Marker3DInteractiveElementOptions); + + /** + * When set, the popover element will be open on this marker's click. + */ + gmpPopoverTargetElement?: PopoverElement; + + /** + * Rollover text. + */ + title: string; + } + + /** + * Marker3DInteractiveElementOptions object used to define the properties that can be set on a Marker3DInteractiveElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Marker3DInteractiveElementOptions + */ + interface Marker3DInteractiveElementOptions extends Marker3DElementOptions { + gmpPopoverTargetElement?: PopoverElement; + title?: string; + } + + /** + * A 3D model which allows the rendering of gLTF models. + * Note that the position and the src must be set for the Model3DElement to display. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Model3DElement + */ + class Model3DElement extends HTMLElement implements Model3DElementOptions { + constructor(options?: Model3DElementOptions); + + /** + * Specifies how altitude in the position is interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * Describes rotation of a 3D model's coordinate system to position the model on the 3D Map. + */ + orientation?: + | google.maps.Orientation3D + | google.maps.Orientation3DLiteral; + + /** + * Sets the Model3DElement's position. + */ + position?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral; + + /** + * Scales the model along the x, y, and z axes in the model's coordinate space. + * @default 1 + */ + scale?: number | google.maps.Vector3D | google.maps.Vector3DLiteral; + + /** + * Specifies the url of the 3D model. At this time, only models in the .glb format are supported. + */ + src?: string | URL; + + addEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + /** + * Model3DElementOptions object used to define the properties that can be set on a Model3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Model3DElementOptions + */ + interface Model3DElementOptions { + altitudeMode?: AltitudeMode; + orientation?: + | google.maps.Orientation3D + | google.maps.Orientation3DLiteral; + position?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral; + scale?: number | google.maps.Vector3D | google.maps.Vector3DLiteral; + src?: string | URL; + } + + /** + * A 3D model with interactive capabilities. + * Unlike Model3DElement, Model3DInteractiveElement receives a gmp-click event. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Model3DInteractiveElement + */ + class Model3DInteractiveElement + extends Model3DElement + implements Model3DInteractiveElementOptions + { + constructor(options?: Model3DElementOptions); + } + + /** + * Model3DInteractiveElementOptions object used to define the properties that can be set on a Model3DInteractiveElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Model3DInteractiveElementOptions + */ + interface Model3DInteractiveElementOptions extends Model3DElementOptions {} + + /** + * A 3D polyline is a linear overlay of connected line segments on a 3D map. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polyline3DElement + */ + class Polyline3DElement + extends HTMLElement + implements Polyline3DElementOptions + { + constructor(options?: Polyline3DElementOptions); + + /** + * Specifies how altitude components in the coordinates are interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * Specifies whether parts of the polyline which could be occluded are drawn or not. + * @default false + */ + drawsOccludedSegments?: boolean; + + /** + * Specifies whether to connect the polyline to the ground. + * @default false + */ + extruded?: boolean; + + /** + * When true, edges of the polyline are interpreted as geodesic. + * @default false + */ + geodesic?: boolean; + + /** + * The outer color. All CSS3 colors are supported. + */ + outerColor?: string; + + /** + * The outer width is between 0.0 and 1.0. This is a percentage of the strokeWidth. + */ + outerWidth?: number; + + /** + * The ordered sequence of coordinates of the Polyline. + */ + path?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + + /** + * The stroke color. All CSS3 colors are supported. + */ + strokeColor?: string; + + /** + * The stroke width in pixels. + */ + strokeWidth?: number; + + /** + * The zIndex compared to other polys. + */ + zIndex?: number; + + /** + * @deprecated Use path instead. + */ + coordinates?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + + addEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + /** + * Polyline3DElementOptions object used to define the properties that can be set on a Polyline3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polyline3DElementOptions + */ + interface Polyline3DElementOptions { + altitudeMode?: AltitudeMode; + /** @deprecated Use path instead. */ + coordinates?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + drawsOccludedSegments?: boolean; + extruded?: boolean; + geodesic?: boolean; + outerColor?: string; + outerWidth?: number; + strokeColor?: string; + strokeWidth?: number; + zIndex?: number; + } + + /** + * A 3D polyline with interactive capabilities. + * Unlike Polyline3DElement, Polyline3DInteractiveElement receives a gmp-click event. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polyline3DInteractiveElement + */ + class Polyline3DInteractiveElement + extends Polyline3DElement + implements Polyline3DInteractiveElementOptions + { + constructor(options?: Polyline3DElementOptions); + } + + /** + * Polyline3DInteractiveElementOptions object used to define the properties that can be set on a Polyline3DInteractiveElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polyline3DInteractiveElementOptions + */ + interface Polyline3DInteractiveElementOptions extends Polyline3DElementOptions {} + + /** + * A 3D polygon defines a series of connected coordinates in an ordered sequence. + * Polygons form a closed loop and define a filled region. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polygon3DElement + */ + class Polygon3DElement + extends HTMLElement + implements Polygon3DElementOptions + { + constructor(options?: Polygon3DElementOptions); + + /** + * Specifies how altitude components in the coordinates are interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * Specifies whether parts of the polygon which could be occluded are drawn or not. + * @default false + */ + drawsOccludedSegments?: boolean; + + /** + * Specifies whether to connect the polygon to the ground. + * @default false + */ + extruded?: boolean; + + /** + * The fill color. All CSS3 colors are supported. + */ + fillColor?: string; + + /** + * When true, edges of the polygon are interpreted as geodesic. + * @default false + */ + geodesic?: boolean; + + /** + * The ordered sequence of coordinates that designates a closed loop. + */ + innerPaths?: Iterable< + Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + > + >; + + /** + * The ordered sequence of coordinates that designates a closed loop. + */ + path?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + + /** + * The stroke color. All CSS3 colors are supported. + */ + strokeColor?: string; + + /** + * The stroke width in pixels. + */ + strokeWidth?: number; + + /** + * The zIndex compared to other polys. + */ + zIndex?: number; + + /** + * @deprecated Use path instead. + */ + outerCoordinates?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + + /** + * @deprecated Use innerPaths instead. + */ + innerCoordinates?: Iterable< + Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + > + >; + + addEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + /** + * Polygon3DElementOptions object used to define the properties that can be set on a Polygon3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polygon3DElementOptions + */ + interface Polygon3DElementOptions { + altitudeMode?: AltitudeMode; + drawsOccludedSegments?: boolean; + extruded?: boolean; + fillColor?: string; + geodesic?: boolean; + /** @deprecated Use innerPaths instead. */ + innerCoordinates?: Iterable< + Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + > + >; + /** @deprecated Use path instead. */ + outerCoordinates?: Iterable< + | google.maps.LatLngAltitude + | google.maps.LatLngAltitudeLiteral + | google.maps.LatLngLiteral + >; + strokeColor?: string; + strokeWidth?: number; + zIndex?: number; + } + + /** + * A 3D polygon with interactive capabilities. + * Unlike Polygon3DElement, Polygon3DInteractiveElement receives a gmp-click event. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polygon3DInteractiveElement + */ + class Polygon3DInteractiveElement + extends Polygon3DElement + implements Polygon3DInteractiveElementOptions + { + constructor(options?: Polygon3DElementOptions); + } + + /** + * Polygon3DInteractiveElementOptions object used to define the properties that can be set on a Polygon3DInteractiveElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Polygon3DInteractiveElementOptions + */ + interface Polygon3DInteractiveElementOptions extends Polygon3DElementOptions {} + + /** + * A custom HTML element that renders a popover. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#PopoverElement + */ + class PopoverElement extends HTMLElement implements PopoverElementOptions { + constructor(options?: PopoverElementOptions); + + /** + * Specifies how the altitude component of the position is interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * + */ + autoPanDisabled?: boolean; + + /** + * Specifies whether this popover should be "light dismissed" or not. + * @default false + */ + lightDismissDisabled?: boolean; + + /** + * Specifies whether this popover should be open or not. + * @default false + */ + open?: boolean; + + /** + * The position at which to display this popover. + */ + positionAnchor?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitudeLiteral + | Marker3DInteractiveElement + | string; + + addEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + + removeEventListener( + type: string, + listener: EventListener | EventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + /** + * PopoverElementOptions object used to define the properties that can be set on a PopoverElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#PopoverElementOptions + */ + interface PopoverElementOptions { + altitudeMode?: AltitudeMode; + autoPanDisabled?: boolean; + lightDismissDisabled?: boolean; + open?: boolean; + positionAnchor?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitudeLiteral + | string + | Marker3DInteractiveElement; + } + + /** + * Specifies how altitude components in the coordinates are interpreted. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#AltitudeMode + */ + enum AltitudeMode { + /** + * Allows to express objects relative to the average mean sea level. + */ + ABSOLUTE = 'ABSOLUTE', + /** + * Allows to express objects placed on the ground. + */ + CLAMP_TO_GROUND = 'CLAMP_TO_GROUND', + /** + * Allows to express objects relative to the ground surface. + */ + RELATIVE_TO_GROUND = 'RELATIVE_TO_GROUND', + /** + * Allows to express objects relative to the highest of ground+building+water surface. + */ + RELATIVE_TO_MESH = 'RELATIVE_TO_MESH' + } + + /** + * Map3DElementOptions object used to define the properties that can be set on a Map3DElement. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Map3DElementOptions + */ + interface Map3DElementOptions { + bounds?: google.maps.LatLngBounds | google.maps.LatLngBoundsLiteral; + center?: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + defaultLabelsDisabled?: boolean; + defaultUIDisabled?: boolean; + gestureHandling?: GestureHandling; + heading?: number; + maxAltitude?: number; + maxHeading?: number; + maxTilt?: number; + minAltitude?: number; + minHeading?: number; + minTilt?: number; + mode?: MapMode; + range?: number; + roll?: number; + tilt?: number; + } + + /** + * Augmentation for Map3DElement to add animation methods. + * The base Map3DElement class is defined in @types/google.maps. + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Map3DElement + */ + interface Map3DElement { + /** + * Starts an animation that makes the camera orbit around a point. + * @param options Configuration options for the animation + */ + flyCameraAround(options: FlyAroundAnimationOptions): void; + + /** + * Starts an animation that moves the camera to a new position. + * @param options Configuration options for the animation + */ + flyCameraTo(options: FlyToAnimationOptions): void; + + /** + * Stops any currently running camera animation. + */ + stopCameraAnimation(): void; + } + } +} diff --git a/examples/map-3d-markers/vite.config.js b/examples/map-3d-markers/vite.config.js new file mode 100644 index 00000000..522c6cb9 --- /dev/null +++ b/examples/map-3d-markers/vite.config.js @@ -0,0 +1,17 @@ +import {defineConfig, loadEnv} from 'vite'; + +export default defineConfig(({mode}) => { + const {GOOGLE_MAPS_API_KEY = ''} = loadEnv(mode, process.cwd(), ''); + + return { + define: { + 'process.env.GOOGLE_MAPS_API_KEY': JSON.stringify(GOOGLE_MAPS_API_KEY) + }, + resolve: { + alias: { + '@vis.gl/react-google-maps/examples.js': + 'https://visgl.github.io/react-google-maps/scripts/examples.js' + } + } + }; +}); diff --git a/src/components/__tests__/__utils__/README.md b/src/components/__tests__/__utils__/README.md new file mode 100644 index 00000000..36d4a2a5 --- /dev/null +++ b/src/components/__tests__/__utils__/README.md @@ -0,0 +1,116 @@ +# Test Utilities for Google Maps 3D Components + +This directory contains mock implementations for Google Maps 3D web components that follow the pattern established by [`@googlemaps/jest-mocks`](https://github.com/googlemaps/js-jest-mocks). + +## Files + +- **`map-3d-mocks.ts`** - Mock implementations of Google Maps 3D web components + +## Usage in Tests + +### Basic Pattern + +The mocks are designed to work with Jest and React Testing Library. Here's the recommended pattern: + +```typescript +import { + Marker3DElement, + Marker3DInteractiveElement, + register3DWebComponentMocks +} from '../../__test-utils__/map-3d-mocks'; + +// Extend mock classes to add spy functionality +class SpyMarker3DElement extends Marker3DElement { + constructor(options?: google.maps.maps3d.Marker3DElementOptions) { + super(options); + if (typeof (globalThis as any).__marker3dFactory === 'function') { + (globalThis as any).__marker3dFactory(this); + } + } +} + +// Register the spy-enabled version +if (!customElements.get('gmp-marker-3d')) { + customElements.define('gmp-marker-3d', SpyMarker3DElement); +} + +// In beforeEach: +beforeEach(() => { + const spy = jest.fn(); + (globalThis as any).__marker3dFactory = (instance: any) => spy(instance); +}); +``` + +### Why This Pattern? + +1. **Custom Elements Can't Be Unregistered**: Once a custom element is defined, it persists for the entire JSDOM instance +2. **Factory Pattern for Fresh Spies**: By using a factory function on `globalThis`, we can create fresh spies for each test +3. **Separation of Concerns**: Base mocks live in `__test-utils__`, test-specific spy wrappers live in test files + +## Mocked Components + +### Marker3DElement + +Mock for `google.maps.maps3d.Marker3DElement` + +- Tag name: `gmp-marker-3d` +- Properties: position, altitudeMode, label, collisionBehavior, drawsWhenOccluded, extruded, sizePreserved, zIndex, title + +### Marker3DInteractiveElement + +Mock for `google.maps.maps3d.Marker3DInteractiveElement` + +- Tag name: `gmp-marker-3d-interactive` +- Extends: Marker3DElement +- Additional properties: gmpPopoverTargetElement + +### PopoverElement + +Mock for `google.maps.maps3d.PopoverElement` + +- Tag name: `gmp-popover` +- Properties: open, positionAnchor, altitudeMode, lightDismissDisabled, autoPanDisabled + +### Map3DElement + +Mock for `google.maps.maps3d.Map3DElement` + +- Tag name: `gmp-map-3d` +- Properties: center, heading, tilt, range, roll, mode, gestureHandling, defaultLabelsDisabled +- Methods: flyCameraAround, flyCameraTo, stopCameraAnimation + +## Key Implementation Details + +### Title Property Handling + +The `title` property uses a getter/setter pattern to avoid conflicts with JSDOM's built-in HTML `title` attribute: + +```typescript +private _title = ''; + +get title(): string { + return this._title; +} + +set title(value: string) { + this._title = value; +} +``` + +### Base Class Pattern + +`BaseMock3DMarker` contains common properties shared between `Marker3DElement` and `Marker3DInteractiveElement` to reduce repetition. + +## Future Plans + +These mocks are designed to be contributed upstream to `@googlemaps/jest-mocks`. They follow the same patterns as the existing mocks in that package: + +- Extend `HTMLElement` +- Accept options in constructor +- Use `Object.assign` to apply options +- Define custom elements with correct tag names + +## Related Issues + +- Google Maps 3D web components: https://developers.google.com/maps/documentation/javascript/reference/3d-map +- `@googlemaps/jest-mocks`: https://github.com/googlemaps/js-jest-mocks diff --git a/src/components/__tests__/__utils__/map-3d-mocks.ts b/src/components/__tests__/__utils__/map-3d-mocks.ts new file mode 100644 index 00000000..4987268b --- /dev/null +++ b/src/components/__tests__/__utils__/map-3d-mocks.ts @@ -0,0 +1,164 @@ +/** + * Mock implementations for Google Maps 3D Web Components. + * + * These mocks follow the pattern established by @googlemaps/jest-mocks + * and are designed to be submitted upstream to that package. + * + * @see https://github.com/googlemaps/js-jest-mocks + */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +/** + * Base class for 3D marker elements that implements common properties. + * Uses getter/setter for 'title' to avoid JSDOM conflicts with built-in HTML attribute. + */ +class BaseMock3DMarker extends HTMLElement { + position?: google.maps.LatLngLiteral; + altitudeMode?: string; + label?: string; + collisionBehavior?: string; + drawsWhenOccluded?: boolean; + extruded?: boolean; + sizePreserved?: boolean; + zIndex?: number; + private _title = ''; + + // Use getter/setter for title to avoid JSDOM conflicts + get title(): string { + return this._title; + } + + set title(value: string) { + this._title = value; + } +} + +/** + * Mock implementation of google.maps.maps3d.Marker3DElement + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Marker3DElement + */ +export class Marker3DElement extends BaseMock3DMarker { + constructor(options?: google.maps.maps3d.Marker3DElementOptions) { + super(); + + // Apply options if provided + if (options) { + Object.assign(this, options); + } + } +} + +/** + * Mock implementation of google.maps.maps3d.Marker3DInteractiveElement + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Marker3DInteractiveElement + */ +export class Marker3DInteractiveElement extends BaseMock3DMarker { + gmpPopoverTargetElement?: google.maps.maps3d.PopoverElement; + + constructor(options?: google.maps.maps3d.Marker3DInteractiveElementOptions) { + super(); + + // Apply options if provided + if (options) { + Object.assign(this, options); + } + } +} + +/** + * Mock implementation of google.maps.maps3d.PopoverElement + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#PopoverElement + */ +export class PopoverElement extends HTMLElement { + open?: boolean; + positionAnchor?: + | google.maps.LatLngLiteral + | google.maps.LatLngAltitudeLiteral + | google.maps.maps3d.Marker3DInteractiveElement + | string; + altitudeMode?: string; + lightDismissDisabled?: boolean; + autoPanDisabled?: boolean; + + constructor(options?: google.maps.maps3d.PopoverElementOptions) { + super(); + + // Apply options if provided + if (options) { + Object.assign(this, options); + } + } +} + +/** + * Mock implementation of google.maps.maps3d.Map3DElement + * @see https://developers.google.com/maps/documentation/javascript/reference/3d-map#Map3DElement + */ +export class Map3DElement extends HTMLElement { + center?: google.maps.LatLngAltitude | google.maps.LatLngAltitudeLiteral; + heading?: number; + tilt?: number; + range?: number; + roll?: number; + mode?: string; + gestureHandling?: string; + defaultLabelsDisabled?: boolean; + + constructor(options?: google.maps.maps3d.Map3DElementOptions) { + super(); + + // Apply options if provided + if (options) { + Object.assign(this, options); + } + } + + flyCameraAround(options: google.maps.maps3d.FlyAroundAnimationOptions): void { + // Mock implementation + } + + flyCameraTo(options: google.maps.maps3d.FlyToAnimationOptions): void { + // Mock implementation + } + + stopCameraAnimation(): void { + // Mock implementation + } + + // HTMLElement methods for child management + appendChild(node: T): T { + return super.appendChild(node); + } + + removeChild(node: T): T { + return super.removeChild(node); + } +} + +/** + * Registers all 3D web component mocks with their correct tag names. + * Call this function once per test file to ensure the custom elements are available. + * + * This function is idempotent - it will only register elements that aren't already defined. + */ +export function register3DWebComponentMocks(): void { + if (!customElements.get('gmp-marker-3d')) { + customElements.define('gmp-marker-3d', Marker3DElement); + } + + if (!customElements.get('gmp-marker-3d-interactive')) { + customElements.define( + 'gmp-marker-3d-interactive', + Marker3DInteractiveElement + ); + } + + if (!customElements.get('gmp-popover')) { + customElements.define('gmp-popover', PopoverElement); + } + + if (!customElements.get('gmp-map-3d')) { + customElements.define('gmp-map-3d', Map3DElement); + } +} diff --git a/src/components/__tests__/map-3d.test.tsx b/src/components/__tests__/map-3d.test.tsx index 89e625b5..ba2d22cb 100644 --- a/src/components/__tests__/map-3d.test.tsx +++ b/src/components/__tests__/map-3d.test.tsx @@ -1,5 +1,5 @@ import {initialize} from '@googlemaps/jest-mocks'; -import {render, screen} from '@testing-library/react'; +import {render, screen, waitFor} from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; @@ -7,10 +7,39 @@ import {Map3D} from '../map-3d'; import {useMapsLibrary} from '../../hooks/use-maps-library'; import {APIProviderContext} from '../api-provider'; import {APILoadingStatus} from '../../libraries/api-loading-status'; +import {Map3DElement} from './__utils__/map-3d-mocks'; jest.mock('../../hooks/use-maps-library'); +// ============================================================================ +// Type declarations for test-specific global factory functions +// ============================================================================ + +declare global { + var __map3dFactory: ((instance: Map3DElement) => void) | undefined; +} + let useMapsLibraryMock: jest.MockedFn; +let createMap3DSpy: jest.Mock; + +// ============================================================================ +// Module-level: Register custom elements ONCE per test file +// ============================================================================ + +// Extend mock class to add spy functionality via factory pattern +class SpyMap3DElement extends Map3DElement { + constructor(options?: google.maps.maps3d.Map3DElementOptions) { + super(options); + if (typeof globalThis.__map3dFactory === 'function') { + globalThis.__map3dFactory(this); + } + } +} + +// Register spy-enabled version of the mock component +if (!customElements.get('gmp-map-3d')) { + customElements.define('gmp-map-3d', SpyMap3DElement); +} // Create a mock context value that satisfies APIProviderContext const createMockContextValue = () => ({ @@ -29,26 +58,63 @@ const createMockContextValue = () => ({ internalUsageAttributionIds: null }); +// ============================================================================ +// Test-level: Create fresh spies for each test +// ============================================================================ + beforeEach(() => { initialize(); + // Create fresh spy for this test + createMap3DSpy = jest.fn(); + + // Attach spy to the custom element constructor via factory function + globalThis.__map3dFactory = (instance: Map3DElement) => { + createMap3DSpy(instance); + }; + useMapsLibraryMock = jest.mocked(useMapsLibrary); - // Return null to prevent element creation (library not loaded) - useMapsLibraryMock.mockReturnValue(null); + // Return empty object (library is no longer used in web component approach) + useMapsLibraryMock.mockReturnValue({} as google.maps.Maps3DLibrary); - // Mock customElements.whenDefined to never resolve - jest.spyOn(customElements, 'whenDefined').mockImplementation( - () => new Promise(() => {}) // Never resolves - ); + // Mock customElements.whenDefined to resolve immediately + jest + .spyOn(customElements, 'whenDefined') + .mockResolvedValue(SpyMap3DElement as unknown as CustomElementConstructor); }); afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); + + // Clean up factory function + delete globalThis.__map3dFactory; }); describe('Map3D', () => { - test('renders container div with data-testid', () => { + test('creates gmp-map-3d element when rendered', async () => { + render( + + + + ); + + // Wait for the custom element to be instantiated + await waitFor(() => { + expect(createMap3DSpy).toHaveBeenCalled(); + }); + + // Verify we got the map3d instance + const map3dInstance = createMap3DSpy.mock.calls[0][0]; + expect(map3dInstance).toBeInstanceOf(Map3DElement); + expect(map3dInstance.tagName.toLowerCase()).toBe('gmp-map-3d'); + }); + + test('renders container div with data-testid', async () => { render( { ); expect(screen.getByTestId('map-3d')).toBeInTheDocument(); + + // Wait for async state updates to complete + await waitFor(() => { + expect(createMap3DSpy).toHaveBeenCalled(); + }); }); - test('applies id prop to container', () => { + test('applies id prop to container', async () => { render( { ); expect(screen.getByTestId('map-3d')).toHaveAttribute('id', 'my-map'); + + // Wait for async state updates to complete + await waitFor(() => { + expect(createMap3DSpy).toHaveBeenCalled(); + }); }); - test('applies className prop to container', () => { + test('applies className prop to container', async () => { render( { ); expect(screen.getByTestId('map-3d')).toHaveClass('custom-class'); + + // Wait for async state updates to complete + await waitFor(() => { + expect(createMap3DSpy).toHaveBeenCalled(); + }); }); - test('applies style prop to container', () => { + test('applies style prop to container', async () => { render( { const container = screen.getByTestId('map-3d'); // Should have both default and custom styles expect(container).toHaveStyle({border: '1px solid red'}); + + // Wait for async state updates to complete + await waitFor(() => { + expect(createMap3DSpy).toHaveBeenCalled(); + }); }); - test('does not render children when map is not ready', () => { + test('renders children after map element is ready', async () => { render( { ); - // Children should NOT be rendered when map3d element isn't available - // (they need the Map3D context which requires the element) - expect(screen.queryByTestId('child-element')).not.toBeInTheDocument(); + // Wait for map to be ready and children to be rendered + await waitFor(() => { + expect(screen.getByTestId('child-element')).toBeInTheDocument(); + }); + + // Verify children have access to the map3d element via context + expect(createMap3DSpy).toHaveBeenCalled(); }); }); diff --git a/src/components/__tests__/marker-3d.test.tsx b/src/components/__tests__/marker-3d.test.tsx index 1339f7a1..cd265bda 100644 --- a/src/components/__tests__/marker-3d.test.tsx +++ b/src/components/__tests__/marker-3d.test.tsx @@ -6,128 +6,150 @@ import React from 'react'; import {Marker3D} from '../marker-3d'; import {useMap3D} from '../../hooks/use-map-3d'; import {useMapsLibrary} from '../../hooks/use-maps-library'; +import { + Marker3DElement, + Marker3DInteractiveElement +} from './__utils__/map-3d-mocks'; jest.mock('../../hooks/use-map-3d'); jest.mock('../../hooks/use-maps-library'); +// ============================================================================ +// Type declarations for test-specific global factory functions +// ============================================================================ + +declare global { + var __marker3dFactory: ((instance: Marker3DElement) => void) | undefined; + var __marker3dInteractiveFactory: + | ((instance: Marker3DInteractiveElement) => void) + | undefined; +} + let useMap3DMock: jest.MockedFn; let useMapsLibraryMock: jest.MockedFn; let createMarkerSpy: jest.Mock; let createInteractiveMarkerSpy: jest.Mock; let mockMap3D: {appendChild: jest.Mock; removeChild: jest.Mock}; -let Marker3DElement: unknown; -let Marker3DInteractiveElement: unknown; + +// ============================================================================ +// Module-level: Register custom elements ONCE per test file +// ============================================================================ + +// Extend mock classes to add spy functionality via factory pattern +class SpyMarker3DElement extends Marker3DElement { + constructor(options?: google.maps.maps3d.Marker3DElementOptions) { + super(options); + if (typeof globalThis.__marker3dFactory === 'function') { + globalThis.__marker3dFactory(this); + } + } +} + +class SpyMarker3DInteractiveElement extends Marker3DInteractiveElement { + constructor(options?: google.maps.maps3d.Marker3DInteractiveElementOptions) { + super(options); + if (typeof globalThis.__marker3dInteractiveFactory === 'function') { + globalThis.__marker3dInteractiveFactory(this); + } + } +} + +// Register spy-enabled versions of the mock components +if (!customElements.get('gmp-marker-3d')) { + customElements.define('gmp-marker-3d', SpyMarker3DElement); +} + +if (!customElements.get('gmp-marker-3d-interactive')) { + customElements.define( + 'gmp-marker-3d-interactive', + SpyMarker3DInteractiveElement + ); +} + +// ============================================================================ +// Test-level: Create fresh spies for each test +// ============================================================================ beforeEach(() => { initialize(); + // Create fresh spies for this test createMarkerSpy = jest.fn(); createInteractiveMarkerSpy = jest.fn(); - mockMap3D = { - appendChild: jest.fn(), - removeChild: jest.fn() + // Attach spies to the custom element constructors via factory functions + globalThis.__marker3dFactory = (instance: Marker3DElement) => { + createMarkerSpy(instance); }; - // Create mock Marker3DElement - Marker3DElement = class extends HTMLElement { - position: google.maps.LatLngLiteral | null = null; - altitudeMode: string | null = null; - label: string | null = null; - collisionBehavior: string | null = null; - drawsWhenOccluded = false; - extruded = false; - sizePreserved = false; - zIndex: number | null = null; - - constructor() { - super(); - createMarkerSpy(this); - } + globalThis.__marker3dInteractiveFactory = ( + instance: Marker3DInteractiveElement + ) => { + createInteractiveMarkerSpy(instance); }; - // Create mock Marker3DInteractiveElement - Marker3DInteractiveElement = class extends HTMLElement { - position: google.maps.LatLngLiteral | null = null; - altitudeMode: string | null = null; - label: string | null = null; - override title = ''; - collisionBehavior: string | null = null; - drawsWhenOccluded = false; - extruded = false; - sizePreserved = false; - zIndex: number | null = null; - - constructor() { - super(); - createInteractiveMarkerSpy(this); - } + // Mock map3d element + mockMap3D = { + appendChild: jest.fn(), + removeChild: jest.fn() }; - // Register with random names to avoid conflicts - customElements.define( - `gmp-marker-3d-${Math.random().toString(36).slice(2)}`, - Marker3DElement as CustomElementConstructor - ); - customElements.define( - `gmp-marker-3d-interactive-${Math.random().toString(36).slice(2)}`, - Marker3DInteractiveElement as CustomElementConstructor - ); - + // Setup hook mocks useMap3DMock = jest.mocked(useMap3D); useMapsLibraryMock = jest.mocked(useMapsLibrary); + // Return mock map3d and library (library is no longer used in web component approach) useMap3DMock.mockReturnValue( mockMap3D as unknown as google.maps.maps3d.Map3DElement ); - useMapsLibraryMock.mockReturnValue({ - Marker3DElement, - Marker3DInteractiveElement - } as unknown as google.maps.Maps3DLibrary); + useMapsLibraryMock.mockReturnValue( + {} as unknown as google.maps.Maps3DLibrary + ); }); afterEach(() => { jest.clearAllMocks(); + + // Clean up factory functions + delete globalThis.__marker3dFactory; + delete globalThis.__marker3dInteractiveFactory; }); describe('Marker3D', () => { - test('creates Marker3DElement after map and library ready', async () => { + test('creates gmp-marker-3d element when rendered', async () => { render(); + // Wait for the custom element to be instantiated await waitFor(() => { expect(createMarkerSpy).toHaveBeenCalled(); }); - expect(mockMap3D.appendChild).toHaveBeenCalled(); + // Verify we got the marker instance + const markerInstance = createMarkerSpy.mock.calls[0][0]; + expect(markerInstance).toBeInstanceOf(Marker3DElement); + expect(markerInstance.tagName.toLowerCase()).toBe('gmp-marker-3d'); }); - test('creates Marker3DInteractiveElement when onClick provided', async () => { + test('creates gmp-marker-3d-interactive when onClick provided', async () => { const onClick = jest.fn(); render( ); + // Wait for the interactive custom element to be instantiated await waitFor(() => { expect(createInteractiveMarkerSpy).toHaveBeenCalled(); }); - expect(createMarkerSpy).not.toHaveBeenCalled(); - }); - - test('does not render when map is not ready', () => { - useMap3DMock.mockReturnValue(null); - - render(); - - expect(createMarkerSpy).not.toHaveBeenCalled(); - }); - - test('does not render when library is not ready', () => { - useMapsLibraryMock.mockReturnValue(null); - - render(); + // Verify we got the interactive marker instance + const markerInstance = createInteractiveMarkerSpy.mock.calls[0][0]; + expect(markerInstance).toBeInstanceOf(Marker3DInteractiveElement); + expect(markerInstance.tagName.toLowerCase()).toBe( + 'gmp-marker-3d-interactive' + ); + // Verify regular marker was NOT created expect(createMarkerSpy).not.toHaveBeenCalled(); }); diff --git a/src/components/__tests__/popover-3d.test.tsx b/src/components/__tests__/popover.test.tsx similarity index 60% rename from src/components/__tests__/popover-3d.test.tsx rename to src/components/__tests__/popover.test.tsx index b3e8be1a..a1b11c9c 100644 --- a/src/components/__tests__/popover-3d.test.tsx +++ b/src/components/__tests__/popover.test.tsx @@ -1,109 +1,113 @@ import {initialize} from '@googlemaps/jest-mocks'; -import {render, waitFor, screen} from '@testing-library/react'; +import {render, screen, waitFor} from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; -import {Popover3D} from '../popover-3d'; +import {Popover} from '../popover'; import {useMap3D} from '../../hooks/use-map-3d'; import {useMapsLibrary} from '../../hooks/use-maps-library'; +import {PopoverElement} from './__utils__/map-3d-mocks'; jest.mock('../../hooks/use-map-3d'); jest.mock('../../hooks/use-maps-library'); +// ============================================================================ +// Type declarations for test-specific global factory functions +// ============================================================================ + +declare global { + var __popoverFactory: ((instance: PopoverElement) => void) | undefined; +} + let useMap3DMock: jest.MockedFn; let useMapsLibraryMock: jest.MockedFn; let createPopoverSpy: jest.Mock; let mockMap3D: {appendChild: jest.Mock; removeChild: jest.Mock}; -let PopoverElement: unknown; + +// ============================================================================ +// Module-level: Register custom elements ONCE per test file +// ============================================================================ + +// Extend mock class to add spy functionality via factory pattern +class SpyPopoverElement extends PopoverElement { + constructor(options?: google.maps.maps3d.PopoverElementOptions) { + super(options); + if (typeof globalThis.__popoverFactory === 'function') { + globalThis.__popoverFactory(this); + } + } +} + +// Register spy-enabled version of the mock component +if (!customElements.get('gmp-popover')) { + customElements.define('gmp-popover', SpyPopoverElement); +} + +// ============================================================================ +// Test-level: Create fresh spies for each test +// ============================================================================ beforeEach(() => { initialize(); + // Create fresh spy for this test createPopoverSpy = jest.fn(); + // Attach spy to the custom element constructor via factory function + globalThis.__popoverFactory = (instance: PopoverElement) => { + createPopoverSpy(instance); + }; + + // Mock map3d element mockMap3D = { appendChild: jest.fn(), removeChild: jest.fn() }; - // Create mock PopoverElement - PopoverElement = class extends HTMLElement { - open = false; - positionAnchor: unknown = null; - altitudeMode: string | null = null; - lightDismissDisabled = false; - - constructor() { - super(); - createPopoverSpy(this); - } - }; - - // Register with random name - customElements.define( - `gmp-popover-${Math.random().toString(36).slice(2)}`, - PopoverElement as CustomElementConstructor - ); - + // Setup hook mocks useMap3DMock = jest.mocked(useMap3D); useMapsLibraryMock = jest.mocked(useMapsLibrary); + // Return mock map3d (library is no longer used in web component approach) useMap3DMock.mockReturnValue( mockMap3D as unknown as google.maps.maps3d.Map3DElement ); - useMapsLibraryMock.mockReturnValue({ - PopoverElement - } as unknown as google.maps.Maps3DLibrary); + useMapsLibraryMock.mockReturnValue( + {} as unknown as google.maps.Maps3DLibrary + ); }); afterEach(() => { jest.clearAllMocks(); + + // Clean up factory function + delete globalThis.__popoverFactory; }); -describe('Popover3D', () => { - test('creates PopoverElement after map and library ready', async () => { +describe('Popover', () => { + test('creates gmp-popover element when rendered', async () => { render( - +
Content
-
+ ); + // Wait for the custom element to be instantiated await waitFor(() => { expect(createPopoverSpy).toHaveBeenCalled(); }); - expect(mockMap3D.appendChild).toHaveBeenCalled(); - }); - - test('does not render when map is not ready', () => { - useMap3DMock.mockReturnValue(null); - - render( - -
Content
-
- ); - - expect(createPopoverSpy).not.toHaveBeenCalled(); - }); - - test('does not render when library is not ready', () => { - useMapsLibraryMock.mockReturnValue(null); - - render( - -
Content
-
- ); - - expect(createPopoverSpy).not.toHaveBeenCalled(); + // Verify we got the popover instance + const popoverInstance = createPopoverSpy.mock.calls[0][0]; + expect(popoverInstance).toBeInstanceOf(PopoverElement); + expect(popoverInstance.tagName.toLowerCase()).toBe('gmp-popover'); }); test('syncs open prop', async () => { const {rerender} = render( - +
Content
-
+ ); await waitFor(() => { @@ -114,9 +118,9 @@ describe('Popover3D', () => { expect(popover.open).toBe(false); rerender( - +
Content
-
+ ); await waitFor(() => { @@ -126,9 +130,9 @@ describe('Popover3D', () => { test('syncs position as positionAnchor', async () => { render( - +
Content
-
+ ); await waitFor(() => { @@ -146,9 +150,9 @@ describe('Popover3D', () => { const mockMarker = {} as google.maps.maps3d.Marker3DInteractiveElement; render( - +
Content
-
+ ); await waitFor(() => { @@ -164,12 +168,12 @@ describe('Popover3D', () => { test('syncs lightDismissDisabled prop', async () => { const {rerender} = render( -
Content
-
+ ); await waitFor(() => { @@ -179,12 +183,12 @@ describe('Popover3D', () => { const popover = createPopoverSpy.mock.calls[0][0]; rerender( -
Content
-
+ ); await waitFor(() => { @@ -201,9 +205,9 @@ describe('Popover3D', () => { }); render( - +
Hello World
-
+ ); await waitFor(() => { @@ -222,9 +226,9 @@ describe('Popover3D', () => { test('cleans up on unmount', async () => { const {unmount} = render( - +
Content
-
+ ); await waitFor(() => { @@ -242,12 +246,9 @@ describe('Popover3D', () => { const refCallback = jest.fn(); render( - +
Content
-
+ ); await waitFor(() => { diff --git a/src/components/info-window.tsx b/src/components/info-window.tsx index 3151b3ac..e50af6ac 100644 --- a/src/components/info-window.tsx +++ b/src/components/info-window.tsx @@ -4,6 +4,7 @@ import React, { PropsWithChildren, ReactNode, useEffect, + useLayoutEffect, useRef, useState } from 'react'; @@ -70,6 +71,8 @@ export const InfoWindow: FunctionComponent< const infoWindowOptions = useMemoized(volatileInfoWindowOptions, isDeepEqual); + // ---- initial mount: create content- and header container, create and + // configure the InfoWindow instance useEffect( () => { if (!mapsLibrary) return; @@ -124,7 +127,7 @@ export const InfoWindow: FunctionComponent< // prevStyleRef stores previously applied style properties, so they can be // removed when unset const prevStyleRef = useRef(null); - useEffect(() => { + useLayoutEffect(() => { if (!infoWindow || !contentContainerRef.current) return; setValueForStyles( @@ -169,7 +172,7 @@ export const InfoWindow: FunctionComponent< [infoWindowOptions, pixelOffset, headerContent] ); - // ## bind event handlers + // ---- bind event handlers useMapsEventListener(infoWindow, 'close', onClose); useMapsEventListener(infoWindow, 'closeclick', onCloseClick); diff --git a/src/components/map-3d/index.tsx b/src/components/map-3d/index.tsx index 0d3c6f8e..5bfafaa2 100644 --- a/src/components/map-3d/index.tsx +++ b/src/components/map-3d/index.tsx @@ -246,13 +246,13 @@ export const Map3D = forwardRef((props, ref) => { style={className ? undefined : combinedStyle} className={className} {...(id ? {id} : {})}> - - - {map3d && ( - - {children} - - )} + + {map3d && ( + + {children} + + )} + ); }); diff --git a/src/components/marker-3d.tsx b/src/components/marker-3d.tsx index dddcf669..84c8b89c 100644 --- a/src/components/marker-3d.tsx +++ b/src/components/marker-3d.tsx @@ -1,20 +1,19 @@ -/* eslint-disable react-hooks/immutability -- Google Maps API objects are designed to be mutated */ -import type {PropsWithChildren, Ref} from 'react'; import React, { createContext, + forwardRef, + PropsWithChildren, + Ref, + useCallback, useContext, useEffect, - useImperativeHandle, useLayoutEffect, useMemo, - useState, - forwardRef + useState } from 'react'; import {createPortal} from 'react-dom'; -import {useMap3D} from '../hooks/use-map-3d'; -import {useMapsLibrary} from '../hooks/use-maps-library'; import {useDomEventListener} from '../hooks/use-dom-event-listener'; +import {usePropBinding} from '../hooks/use-prop-binding'; import {CollisionBehavior} from './advanced-marker'; // Re-export CollisionBehavior for convenience @@ -144,8 +143,7 @@ export const Marker3D = forwardRef(function Marker3D( title } = props; - const map3d = useMap3D(); - const maps3dLibrary = useMapsLibrary('maps3d'); + const isInteractive = Boolean(onClick); const [marker, setMarker] = useState< | google.maps.maps3d.Marker3DElement @@ -157,103 +155,33 @@ export const Marker3D = forwardRef(function Marker3D( const [contentHandledExternally, setContentHandledExternally] = useState(false); - // Container for rendering React children before moving to marker - const [contentContainer, setContentContainer] = - useState(null); - - const isInteractive = Boolean(onClick); - - useImperativeHandle( - ref, - () => - marker as - | google.maps.maps3d.Marker3DElement - | google.maps.maps3d.Marker3DInteractiveElement, - [marker] - ); - - useEffect(() => { - if (!map3d || !maps3dLibrary) return; - - let newMarker: - | google.maps.maps3d.Marker3DElement - | google.maps.maps3d.Marker3DInteractiveElement; - - if (isInteractive) { - newMarker = new maps3dLibrary.Marker3DInteractiveElement(); - } else { - newMarker = new maps3dLibrary.Marker3DElement(); - } - - map3d.appendChild(newMarker); - setMarker(newMarker); - - // Hidden container used as React portal target for custom marker content + // Create a container for rendering React children to be wrapped and relocated + // into the parent gmp-marker-3d element. + const contentContainer = useMemo(() => { const container = document.createElement('div'); container.style.display = 'none'; document.body.appendChild(container); - setContentContainer(container); - return () => { - if (newMarker.parentElement) { - newMarker.parentElement.removeChild(newMarker); - } - container.remove(); - setMarker(null); - setContentContainer(null); - }; - }, [map3d, maps3dLibrary, isInteractive]); - - useEffect(() => { - if (!marker || position === undefined) return; - marker.position = position; - }, [marker, position]); + return container; + }, []); + // Remove the container on unmount useEffect(() => { - if (!marker || altitudeMode === undefined) return; - marker.altitudeMode = - altitudeMode as unknown as google.maps.maps3d.AltitudeMode; - }, [marker, altitudeMode]); - - useEffect(() => { - if (!marker || collisionBehavior === undefined) return; - marker.collisionBehavior = - collisionBehavior as google.maps.CollisionBehavior; - }, [marker, collisionBehavior]); - - useEffect(() => { - if (!marker) return; - if (drawsWhenOccluded !== undefined) - marker.drawsWhenOccluded = drawsWhenOccluded; - }, [marker, drawsWhenOccluded]); - - useEffect(() => { - if (!marker) return; - if (extruded !== undefined) marker.extruded = extruded; - }, [marker, extruded]); - - useEffect(() => { - if (!marker) return; - if (label !== undefined) marker.label = label; - }, [marker, label]); - - useEffect(() => { - if (!marker) return; - if (sizePreserved !== undefined) marker.sizePreserved = sizePreserved; - }, [marker, sizePreserved]); - - useEffect(() => { - if (!marker) return; - if (zIndex !== undefined) marker.zIndex = zIndex; - }, [marker, zIndex]); - - // title is only available on Marker3DInteractiveElement - useEffect(() => { - if (!marker || !isInteractive) return; - const interactiveMarker = - marker as google.maps.maps3d.Marker3DInteractiveElement; - if (title !== undefined) interactiveMarker.title = title; - }, [marker, title, isInteractive]); + return () => contentContainer.remove(); + }, [contentContainer]); + + // Callback ref that sets both internal state and forwards the ref + const markerRef = useCallback( + (node: google.maps.maps3d.Marker3DElement | null) => { + setMarker(node); + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + (ref as React.RefObject).current = node; + } + }, + [ref] + ); useDomEventListener(marker, 'gmp-click', onClick); @@ -289,10 +217,27 @@ export const Marker3D = forwardRef(function Marker3D( [marker] ); - if (!contentContainer) return null; + usePropBinding(marker, 'position', position); + usePropBinding( + marker, + 'altitudeMode', + altitudeMode as google.maps.maps3d.AltitudeMode + ); + usePropBinding(marker, 'collisionBehavior', collisionBehavior); + usePropBinding(marker, 'drawsWhenOccluded', drawsWhenOccluded); + usePropBinding(marker, 'extruded', extruded); + usePropBinding(marker, 'label', label); + usePropBinding(marker, 'sizePreserved', sizePreserved); + usePropBinding(marker, 'zIndex', zIndex); + usePropBinding(marker, 'title', title ?? ''); return ( + {isInteractive ? ( + + ) : ( + + )} {createPortal(children, contentContainer)} ); diff --git a/src/components/popover-3d.tsx b/src/components/popover-3d.tsx deleted file mode 100644 index ed02633f..00000000 --- a/src/components/popover-3d.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/* eslint-disable react-hooks/immutability -- Google Maps API objects are designed to be mutated */ -import type {PropsWithChildren, Ref} from 'react'; -import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'; -import {createPortal} from 'react-dom'; - -import {useMap3D} from '../hooks/use-map-3d'; -import {useMapsLibrary} from '../hooks/use-maps-library'; -import {useDomEventListener} from '../hooks/use-dom-event-listener'; -import {AltitudeMode} from './marker-3d'; - -// Re-export AltitudeMode for convenience -export {AltitudeMode}; - -/** - * Event props for Popover3D component. - */ -type Popover3DEventProps = { - /** Called when the popover is closed via light dismiss (click outside). */ - onClose?: (e: Event) => void; -}; - -/** - * Props for the Popover3D component. - */ -export type Popover3DProps = PropsWithChildren< - Omit< - google.maps.maps3d.PopoverElementOptions, - 'altitudeMode' | 'positionAnchor' - > & - Popover3DEventProps & { - /** - * Specifies how the altitude component of the position is interpreted. - * @default AltitudeMode.CLAMP_TO_GROUND - */ - altitudeMode?: AltitudeMode; - - /** - * The position at which to display this popover. - * Can be a LatLng position or LatLngAltitude position. - */ - position?: google.maps.LatLngLiteral | google.maps.LatLngAltitudeLiteral; - - /** - * A Marker3DInteractiveElement to anchor the popover to. - * When specified, the popover will be positioned relative to the marker. - */ - anchor?: google.maps.maps3d.Marker3DInteractiveElement | null; - - /** - * A string ID referencing a Marker3DInteractiveElement to anchor the popover to. - */ - anchorId?: string; - } ->; - -/** - * Popover3D component for displaying info windows on a Map3D. - * - * Similar to InfoWindow for 2D maps, Popover3D provides a way to show - * contextual information at a specific location or attached to a marker - * on a 3D map. - * - * @example - * ```tsx - * // Basic popover at position - * - *
Hello from San Francisco!
- *
- * - * // Popover anchored to a marker (place as sibling, use anchor prop) - * setOpen(true)} - * /> - * setOpen(false)} - * > - *
Marker info
- *
- * ``` - */ -export const Popover3D = forwardRef(function Popover3D( - props: Popover3DProps, - ref: Ref -) { - const { - children, - open, - position, - anchor, - anchorId, - altitudeMode, - lightDismissDisabled, - onClose - } = props; - - const map3d = useMap3D(); - const maps3dLibrary = useMapsLibrary('maps3d'); - - const [popover, setPopover] = - useState(null); - - // Container for rendering React children - const [contentContainer, setContentContainer] = - useState(null); - - useImperativeHandle(ref, () => popover as google.maps.maps3d.PopoverElement, [ - popover - ]); - - useEffect(() => { - if (!map3d || !maps3dLibrary) return; - - // PopoverElement may not be available in all versions - if (!('PopoverElement' in maps3dLibrary)) { - console.warn( - 'PopoverElement is not available in the current Maps API version' - ); - return; - } - - const newPopover = new maps3dLibrary.PopoverElement(); - - // Container element serves as React portal target for children - const container = document.createElement('div'); - setContentContainer(container); - newPopover.appendChild(container); - - map3d.appendChild(newPopover); - setPopover(newPopover); - - return () => { - if (newPopover.parentElement) { - newPopover.parentElement.removeChild(newPopover); - } - setPopover(null); - setContentContainer(null); - }; - }, [map3d, maps3dLibrary]); - - useEffect(() => { - if (!popover) return; - popover.open = open ?? false; - }, [popover, open]); - - // positionAnchor accepts a position, marker element, or marker ID string - useEffect(() => { - if (!popover) return; - - if (anchor) { - popover.positionAnchor = anchor; - } else if (anchorId) { - popover.positionAnchor = anchorId; - } else if (position) { - popover.positionAnchor = position; - } - }, [popover, position, anchor, anchorId]); - - useEffect(() => { - if (!popover || altitudeMode === undefined) return; - popover.altitudeMode = - altitudeMode as unknown as google.maps.maps3d.AltitudeMode; - }, [popover, altitudeMode]); - - useEffect(() => { - if (!popover) return; - if (lightDismissDisabled !== undefined) { - popover.lightDismissDisabled = lightDismissDisabled; - } - }, [popover, lightDismissDisabled]); - - useDomEventListener(popover, 'gmp-close', onClose); - - // Render children directly into contentContainer which is already inside the popover - if (!contentContainer) return null; - - return createPortal(children, contentContainer); -}); - -Popover3D.displayName = 'Popover3D'; diff --git a/src/components/popover.tsx b/src/components/popover.tsx new file mode 100644 index 00000000..cbb7f634 --- /dev/null +++ b/src/components/popover.tsx @@ -0,0 +1,224 @@ +import React, { + CSSProperties, + ForwardedRef, + forwardRef, + PropsWithChildren, + ReactNode, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState +} from 'react'; + +import {usePropBinding} from '../hooks/use-prop-binding'; +import {setValueForStyles} from '../libraries/set-value-for-styles'; +import {AltitudeMode} from './marker-3d'; + +// Re-export AltitudeMode for convenience +export {AltitudeMode}; + +/** + * Event props for Popover component. + */ +type PopoverEventProps = { + /** Called when the popover is closed via light dismiss (click outside). */ + onClose?: () => void; + + /** + * Content to render in the header slot of the popover. + */ + headerContent?: ReactNode; +}; + +/** + * Props for the Popover component. + */ +export type PopoverProps = PropsWithChildren< + Omit< + google.maps.maps3d.PopoverElementOptions, + 'altitudeMode' | 'positionAnchor' + > & + PopoverEventProps & { + /** + * Specifies how the altitude component of the position is interpreted. + * @default AltitudeMode.CLAMP_TO_GROUND + */ + altitudeMode?: AltitudeMode; + + /** + * The position at which to display this popover. + * Can be a LatLng position or LatLngAltitude position. + */ + position?: google.maps.LatLngLiteral | google.maps.LatLngAltitudeLiteral; + + /** + * A Marker3DInteractiveElement to anchor the popover to. + * When specified, the popover will be positioned relative to the marker. + */ + anchor?: google.maps.maps3d.Marker3DInteractiveElement | null; + + /** + * A string ID referencing a Marker3DInteractiveElement to anchor the popover to. + */ + anchorId?: string; + + style?: CSSProperties; + + className?: string; + } +>; + +/** + * Popover component for displaying info windows on a Map3D. + * + * Similar to InfoWindow for 2D maps, Popover provides a way to show + * contextual information at a specific location or attached to a marker + * on a 3D map. + * + * @example + * ```tsx + * // Basic popover at position + * + *
Hello from San Francisco!
+ *
+ * + * // Popover anchored to a marker (place as sibling, use anchor prop) + * setOpen(true)} + * /> + * setOpen(false)} + * > + *
Marker info
+ *
+ * ``` + */ +export const Popover = forwardRef(function Popover( + props: PopoverProps, + ref: ForwardedRef +) { + const { + children, + headerContent, + style, + className, + open = true, + position, + anchor, + anchorId, + altitudeMode, + lightDismissDisabled, + autoPanDisabled, + onClose + } = props; + + const [popover, setPopover] = + useState(null); + + const prevStyleRef = useRef(null); + + // Forward the ref to the parent + useImperativeHandle(ref, () => popover!, [popover]); + + // Observe the open attribute and call onClose when popover is automatically + // closed by the Maps API (light dismiss) + usePopoverCloseObserver(popover, open, onClose); + + // Set properties on the popover element + usePropBinding(popover, 'open', open ?? false); + usePropBinding( + popover, + 'altitudeMode', + altitudeMode as google.maps.maps3d.AltitudeMode + ); + usePropBinding(popover, 'lightDismissDisabled', lightDismissDisabled); + usePropBinding(popover, 'autoPanDisabled', autoPanDisabled); + + // positionAnchor accepts a position, marker element, or marker ID string + const positionAnchor = anchor ?? anchorId ?? position; + usePropBinding(popover, 'positionAnchor', positionAnchor); + + // Set styles via ref for compatibility with older React versions + useLayoutEffect(() => { + if (!popover) return; + + setValueForStyles(popover, style || null, prevStyleRef.current); + prevStyleRef.current = style || null; + }, [popover, style]); + + return ( + + {headerContent &&
{headerContent}
} + {children} +
+ ); +}); + +Popover.displayName = 'Popover'; + +/** + * Custom hook to observe the open attribute of a popover element + * and call onClose when it transitions from open to closed due to light dismiss. + * Does not call onClose when the open prop changes programmatically. + */ +function usePopoverCloseObserver( + popover: google.maps.maps3d.PopoverElement | null, + open: boolean | undefined, + onClose?: () => void +) { + const previousOpenState = useRef(undefined); + const openPropRef = useRef(open); + + // Track the open prop value + useEffect(() => { + openPropRef.current = open; + }, [open]); + + useEffect(() => { + if (!popover || !onClose) return; + + const observer = new MutationObserver(mutations => { + for (const mutation of mutations) { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'open' + ) { + const isOpen = popover.hasAttribute('open'); + + // Only call onClose when: + // 1. Transitioning from open to closed + // 2. The prop hasn't changed to false (meaning this was light dismiss, not programmatic) + if ( + previousOpenState.current === true && + !isOpen && + openPropRef.current !== false + ) { + onClose(); + } + + previousOpenState.current = isOpen; + } + } + }); + + observer.observe(popover, { + attributes: true, + attributeFilter: ['open'] + }); + + // Initialize the previous state + previousOpenState.current = popover.hasAttribute('open'); + + return () => { + observer.disconnect(); + }; + }, [popover, onClose]); +} diff --git a/src/custom-elements-types/maps3d.d.ts b/src/custom-elements-types/maps3d.d.ts index fe484cda..d5a1f056 100644 --- a/src/custom-elements-types/maps3d.d.ts +++ b/src/custom-elements-types/maps3d.d.ts @@ -145,7 +145,7 @@ type Polygon3DProps = { 'z-index'?: string; }; -type PopoverProps = { +type PopoverElementProps = { open?: boolean | string | null; altitudeMode?: google.maps.maps3d.AltitudeMode | null; @@ -208,7 +208,10 @@ declare module 'react' { google.maps.Polygon3DInteractiveElement >; - 'gmp-popover': CustomElement; + 'gmp-popover': CustomElement< + PopoverElementProps, + google.maps.PopoverElement + >; } } } diff --git a/src/index.ts b/src/index.ts index 0218e2ce..f3d9cdde 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ export * from './components/info-window'; export * from './components/map'; export * from './components/map-3d'; export * from './components/marker-3d'; -export * from './components/popover-3d'; +export * from './components/popover'; export * from './components/static-map'; export * from './components/map-control'; export * from './components/marker'; diff --git a/types/google.maps.d.ts b/types/google.maps.d.ts index b017c85a..d9d0dc84 100644 --- a/types/google.maps.d.ts +++ b/types/google.maps.d.ts @@ -686,6 +686,8 @@ declare namespace google.maps { */ lightDismissDisabled?: boolean; + autoPanDisabled?: boolean; + /** * Specifies whether this popover should be open or not. * @default false @@ -720,6 +722,7 @@ declare namespace google.maps { */ interface PopoverElementOptions { altitudeMode?: AltitudeMode; + autoPanDisabled?: boolean; lightDismissDisabled?: boolean; open?: boolean; positionAnchor?: