From 20815dc46086b6773d7f0d364afe9bec8aff5412 Mon Sep 17 00:00:00 2001 From: Malte Modrow Date: Tue, 7 Oct 2025 16:41:17 +0200 Subject: [PATCH 1/5] fix: make pointer events more robust --- .../advanced-marker-interaction/src/app.tsx | 48 +++++++++--- .../advanced-marker-interaction/src/data.ts | 16 +++- .../advanced-marker-interaction/tsconfig.json | 11 +++ .../types/global.d.ts | 7 ++ src/components/advanced-marker.tsx | 74 +++++++++---------- src/components/info-window.tsx | 14 ++-- src/components/pin.tsx | 4 +- src/libraries/global-style-manager.ts | 38 ++++++++++ 8 files changed, 152 insertions(+), 60 deletions(-) create mode 100644 examples/advanced-marker-interaction/tsconfig.json create mode 100644 examples/advanced-marker-interaction/types/global.d.ts create mode 100644 src/libraries/global-style-manager.ts diff --git a/examples/advanced-marker-interaction/src/app.tsx b/examples/advanced-marker-interaction/src/app.tsx index ccb2f1bc..22eb8391 100644 --- a/examples/advanced-marker-interaction/src/app.tsx +++ b/examples/advanced-marker-interaction/src/app.tsx @@ -13,10 +13,10 @@ import { CollisionBehavior } from '@vis.gl/react-google-maps'; -import {getData} from './data'; - import ControlPanel from './control-panel'; +import {getData, MarkerType, textSnippets} from './data'; + import './style.css'; export type AnchorPointName = keyof typeof AdvancedMarkerAnchorPoint; @@ -27,7 +27,10 @@ export type AnchorPointName = keyof typeof AdvancedMarkerAnchorPoint; // thus appear in front. const data = getData() .sort((a, b) => b.position.lat - a.position.lat) - .map((dataItem, index) => ({...dataItem, zIndex: index})); + .map((dataItem, index) => ({ + ...dataItem, + zIndex: index + })); const Z_INDEX_SELECTED = data.length; const Z_INDEX_HOVER = data.length + 1; @@ -40,8 +43,13 @@ const App = () => { const [hoverId, setHoverId] = useState(null); const [selectedId, setSelectedId] = useState(null); + const [infowindowContent, setInfowindowContent] = useState( + null + ); - const [anchorPoint, setAnchorPoint] = useState('BOTTOM' as AnchorPointName); + const [anchorPoint, setAnchorPoint] = useState( + 'LEFT_CENTER' as AnchorPointName + ); const [selectedMarker, setSelectedMarker] = useState(null); const [infoWindowShown, setInfoWindowShown] = useState(false); @@ -49,13 +57,19 @@ const App = () => { const onMouseEnter = useCallback((id: string | null) => setHoverId(id), []); const onMouseLeave = useCallback(() => setHoverId(null), []); const onMarkerClick = useCallback( - (id: string | null, marker?: google.maps.marker.AdvancedMarkerElement) => { + ( + id: string | null, + marker?: google.maps.marker.AdvancedMarkerElement, + type?: MarkerType + ) => { setSelectedId(id); if (marker) { setSelectedMarker(marker); } + setInfowindowContent(type ? textSnippets[type] : null); + if (id !== selectedId) { setInfoWindowShown(true); } else { @@ -97,12 +111,25 @@ const App = () => { zIndex = Z_INDEX_SELECTED; } + if (type === 'default') { + return ( + onMarkerClick(id, marker, type)} + onMouseEnter={() => onMouseEnter(id)} + onMouseLeave={onMouseLeave} + /> + ); + } + if (type === 'pin') { return ( onMarkerClick(id, marker)} + ) => onMarkerClick(id, marker, type)} onMouseEnter={() => onMouseEnter(id)} onMouseLeave={onMouseLeave} key={id} @@ -114,7 +141,7 @@ const App = () => { }} position={position}> @@ -137,7 +164,9 @@ const App = () => { }} onMarkerClick={( marker: google.maps.marker.AdvancedMarkerElement - ) => onMarkerClick(id, marker)} + ) => { + onMarkerClick(id, marker, type); + }} onMouseEnter={() => onMouseEnter(id)} collisionBehavior={ CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY @@ -166,11 +195,12 @@ const App = () => { {infoWindowShown && selectedMarker && (

Marker {selectedId}

-

Some arbitrary html to be rendered into the InfoWindow.

+

{infowindowContent}

)} diff --git a/examples/advanced-marker-interaction/src/data.ts b/examples/advanced-marker-interaction/src/data.ts index 57790275..0c37cc58 100644 --- a/examples/advanced-marker-interaction/src/data.ts +++ b/examples/advanced-marker-interaction/src/data.ts @@ -1,20 +1,32 @@ +export type MarkerType = 'default' | 'pin' | 'html'; + type MarkerData = Array<{ id: string; position: google.maps.LatLngLiteral; - type: 'pin' | 'html'; + type: MarkerType; zIndex: number; + infowindowContent?: string; }>; +export const textSnippets = { + default: 'This is a default AdvancedMarkerElement without custom content', + pin: 'This is a AdvancedMarkerElement with custom pin-style marker', + html: 'This is a AdvancedMarkerElement with custom HTML content' +} as const; + export function getData() { const data: MarkerData = []; // create 50 random markers for (let index = 0; index < 50; index++) { + const type = + Math.random() < 0.1 ? 'default' : Math.random() < 0.5 ? 'pin' : 'html'; + data.push({ id: String(index), position: {lat: rnd(53.52, 53.63), lng: rnd(9.88, 10.12)}, zIndex: index, - type: Math.random() < 0.5 ? 'pin' : 'html' + type }); } diff --git a/examples/advanced-marker-interaction/tsconfig.json b/examples/advanced-marker-interaction/tsconfig.json new file mode 100644 index 00000000..dca80a4f --- /dev/null +++ b/examples/advanced-marker-interaction/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@vis.gl/react-google-maps": ["../../src"] + } + }, + "include": ["src/**/*", "../../src/**/*", "./types/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/advanced-marker-interaction/types/global.d.ts b/examples/advanced-marker-interaction/types/global.d.ts new file mode 100644 index 00000000..b65b2bbc --- /dev/null +++ b/examples/advanced-marker-interaction/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/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index 9dbabf79..5e35f2b7 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -18,6 +18,7 @@ import type {Ref, PropsWithChildren} from 'react'; import {useMapsEventListener} from '../hooks/use-maps-event-listener'; import {usePropBinding} from '../hooks/use-prop-binding'; import {useDomEventListener} from '../hooks/use-dom-event-listener'; +import {globalStyleManager} from '../libraries/global-style-manager'; export interface AdvancedMarkerContextValue { marker: google.maps.marker.AdvancedMarkerElement; @@ -119,42 +120,16 @@ type MarkerContentProps = PropsWithChildren & { anchorPoint?: AdvancedMarkerAnchorPoint | [string, string]; }; -const MarkerContent = ({ - children, - styles, - className, - anchorPoint -}: MarkerContentProps) => { - const [xTranslation, yTranslation] = - anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; - - let xTranslationFlipped = `-${xTranslation}`; - let yTranslationFlipped = `-${yTranslation}`; - if (xTranslation.trimStart().startsWith('-')) { - xTranslationFlipped = xTranslation.substring(1); - } - if (yTranslation.trimStart().startsWith('-')) { - yTranslationFlipped = yTranslation.substring(1); - } - - // The "translate(50%, 100%)" is here to counter and reset the default anchoring of the advanced marker element - // that comes from the api - const transformStyle = `translate(50%, 100%) translate(${xTranslationFlipped}, ${yTranslationFlipped})`; - +const MarkerContent = ({children, styles, className}: MarkerContentProps) => { + /* AdvancedMarker div that user can give styles and classes */ return ( - // anchoring container -
- {/* AdvancedMarker div that user can give styles and classes */} -
- {children} -
+
+ {children}
); }; -export type CustomMarkerContent = - | (HTMLDivElement & {isCustomMarker?: boolean}) - | null; +export type CustomMarkerContent = HTMLDivElement | null; export type AdvancedMarkerRef = google.maps.marker.AdvancedMarkerElement | null; function useAdvancedMarker(props: AdvancedMarkerProps) { @@ -180,11 +155,15 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { draggable, position, title, - zIndex + zIndex, + anchorPoint } = props; const numChildren = Children.count(children); + const [xTranslation, yTranslation] = + anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; + // create an AdvancedMarkerElement instance and add it to the map once available useEffect(() => { if (!map || !markerLibrary) return; @@ -200,9 +179,25 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { contentElement = document.createElement('div'); // We need some kind of flag to identify the custom marker content - // in the infowindow component. Choosing a custom property instead of a className - // to not encourage users to style the marker content directly. - contentElement.isCustomMarker = true; + // in the infowindow component. Choosing a data attribute to also be able + // to target it via CSS to disable pointer event when using custom anchor point + newMarker.dataset.origin = 'rgm'; + + let xTranslationFlipped = `-${xTranslation}`; + let yTranslationFlipped = `-${yTranslation}`; + + if (xTranslation.trimStart().startsWith('-')) { + xTranslationFlipped = xTranslation.substring(1); + } + if (yTranslation.trimStart().startsWith('-')) { + yTranslationFlipped = yTranslation.substring(1); + } + + // The "translate(50%, 100%)" is here to counter and reset the default anchoring of the advanced marker element + // that comes from the api + const transformStyle = `translate(50%, 100%) translate(${xTranslationFlipped}, ${yTranslationFlipped})`; + contentElement.style.transform = transformStyle; + globalStyleManager.addAdvancedMarkerPointerEventsOverwrite(); newMarker.content = contentElement; setContentContainer(contentElement); @@ -214,7 +209,7 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { setMarker(null); setContentContainer(null); }; - }, [map, markerLibrary, numChildren]); + }, [xTranslation, yTranslation, map, markerLibrary, numChildren]); // When no children are present we don't have our own wrapper div // which usually gets the user provided className. In this case @@ -264,11 +259,10 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { // enable pointer events for the markers with custom content if (gmpClickable && marker?.content && isElementNode(marker.content)) { - marker.content.style.pointerEvents = 'none'; + marker.content.style.pointerEvents = 'all'; - if (marker.content.firstElementChild) { - (marker.content.firstElementChild as HTMLElement).style.pointerEvents = - 'all'; + if (onClick) { + marker.content.style.cursor = 'pointer'; } } }, [marker, clickable, onClick, onMouseEnter, onMouseLeave]); diff --git a/src/components/info-window.tsx b/src/components/info-window.tsx index 384134a8..3f252430 100644 --- a/src/components/info-window.tsx +++ b/src/components/info-window.tsx @@ -16,7 +16,7 @@ import {useMapsEventListener} from '../hooks/use-maps-event-listener'; import {useMapsLibrary} from '../hooks/use-maps-library'; import {useMemoized} from '../hooks/use-memoized'; import {setValueForStyles} from '../libraries/set-value-for-styles'; -import {CustomMarkerContent, isAdvancedMarker} from './advanced-marker'; +import {isAdvancedMarker} from './advanced-marker'; export type InfoWindowProps = Omit< google.maps.InfoWindowOptions, @@ -187,14 +187,13 @@ export const InfoWindow: FunctionComponent< // Only do the infowindow adjusting when dealing with an AdvancedMarker if (isAdvancedMarker(anchor) && anchor.content instanceof Element) { - const wrapper = anchor.content as CustomMarkerContent; - const wrapperBcr = wrapper?.getBoundingClientRect(); + const anchorBcr = anchor?.getBoundingClientRect(); // This checks whether or not the anchor has custom content with our own // div wrapper. If not, that means we have a regular AdvancedMarker without any children. // In that case we do not want to adjust the infowindow since it is all handled correctly // by the Google Maps API. - if (wrapperBcr && wrapper?.isCustomMarker) { + if (anchorBcr && anchor.dataset.origin === 'rgm') { // We can safely typecast here since we control that element and we know that // it is a div const anchorDomContent = anchor.content.firstElementChild @@ -205,9 +204,10 @@ export const InfoWindow: FunctionComponent< // center infowindow above marker const anchorOffsetX = contentBcr.x - - wrapperBcr.x + - (contentBcr.width - wrapperBcr.width) / 2; - const anchorOffsetY = contentBcr.y - wrapperBcr.y; + anchorBcr.x + + (contentBcr.width - anchorBcr.width) / 2; + + const anchorOffsetY = contentBcr.y - anchorBcr.y; const opts: google.maps.InfoWindowOptions = infoWindowOptions; diff --git a/src/components/pin.tsx b/src/components/pin.tsx index e6044e96..78a85943 100644 --- a/src/components/pin.tsx +++ b/src/components/pin.tsx @@ -61,8 +61,8 @@ export const Pin: FunctionComponent = props => { // Set content of Advanced Marker View to the Pin View element // Here we are selecting the anchor container. // The hierarchy is as follows: - // "advancedMarker.content" (from google) -> "pointer events reset div" -> "anchor container" - const markerContent = advancedMarker.content?.firstChild?.firstChild; + // "advancedMarker.content" (from google) -> "anchor container" + const markerContent = advancedMarker.content?.firstChild; while (markerContent?.firstChild) { markerContent.removeChild(markerContent.firstChild); diff --git a/src/libraries/global-style-manager.ts b/src/libraries/global-style-manager.ts new file mode 100644 index 00000000..879fc84c --- /dev/null +++ b/src/libraries/global-style-manager.ts @@ -0,0 +1,38 @@ +// Global style manager to track rendered styles and avoid duplicates +class GlobalStyleManager { + private renderedStyles = new Set(); + private styleElement: HTMLStyleElement | null = null; + + private getStyleElement(): HTMLStyleElement { + if (!this.styleElement) { + this.styleElement = document.createElement('style'); + this.styleElement.setAttribute('data-rgm-anchor-styles', ''); + document.head.appendChild(this.styleElement); + } + return this.styleElement; + } + + addAdvancedMarkerPointerEventsOverwrite(): void { + if (this.renderedStyles.has('marker-pointer-events')) { + return; + } + + const styleElement = this.getStyleElement(); + styleElement.textContent += ` + gmp-advanced-marker[data-origin='rgm'] { + pointer-events: none !important; + } + `; + this.renderedStyles.add('marker-pointer-events'); + } + + cleanup(): void { + if (this.styleElement) { + this.styleElement.remove(); + this.styleElement = null; + this.renderedStyles.clear(); + } + } +} + +export const globalStyleManager = new GlobalStyleManager(); From 5800cae43e6d15457b390d115d8e275abed71e13 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Mon, 20 Oct 2025 17:11:45 +0200 Subject: [PATCH 2/5] feat(advanced-marker): add anchorLeft and anchorTop props Adds support for the 'anchorLeft' and 'anchorTop' properties on the AdvancedMarker component. This provides a more direct way to control the anchor point of the marker, especially with modern versions of the Google Maps JavaScript API. The implementation includes: - A new internal 'useAdvancedMarkerAnchorPoint' hook to encapsulate anchoring logic. - Version detection to use native 'anchorLeft'/'anchorTop' properties on Google Maps API v3.62+ and fallback to a CSS transform on older versions. - A warning is logged when using the new props on unsupported API versions. - Added TypeScript definitions of anchor options to type augmentation - Added API documentation. --- .../components/advanced-marker.md | 19 ++ src/components/advanced-marker.tsx | 206 ++++++++++++------ src/libraries/version-utils.ts | 15 ++ types/google.maps.d.ts | 26 ++- 4 files changed, 193 insertions(+), 73 deletions(-) create mode 100644 src/libraries/version-utils.ts diff --git a/docs/api-reference/components/advanced-marker.md b/docs/api-reference/components/advanced-marker.md index a89aa10f..e9162048 100644 --- a/docs/api-reference/components/advanced-marker.md +++ b/docs/api-reference/components/advanced-marker.md @@ -173,6 +173,25 @@ The position is measured from the top-left corner and can be anything that can be consumed by a CSS translate() function. For example in percent `[10%, 90%]` or in pixels `[10px, 20px]`. +#### `anchorLeft`: string + +A [CSS length-percentage] value which is used to translate the marker +content relative to the anchor point. A value of 0 means the anchor-point +will be at the left edge of the content-element. The default value is `-%50`, +so the anchor point will be at the center of the content element. +You can also use CSS `calc()` expressions to combine percentage and pixel +values. + +#### `anchorTop`: string + +A [CSS length-percentage] value which is used to translate the marker content +relative to the anchor point. When this value is 0, the anchor-point will be +at the top-edge of the content element. The default value is `-%100`, which +places the anchor-point at the bottom edge. You can also use CSS `calc()` +expressions to combine percentage and pixel values. + +[CSS length-percentage]: https://developer.mozilla.org/en-US/docs/Web/CSS/length-percentage + ### Other Props #### `clickable`: boolean diff --git a/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index 0ecff488..591f4173 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -1,3 +1,4 @@ +import type {PropsWithChildren, Ref} from 'react'; import React, { Children, CSSProperties, @@ -12,29 +13,16 @@ import React, { import {createPortal} from 'react-dom'; import {useMap} from '../hooks/use-map'; import {useMapsLibrary} from '../hooks/use-maps-library'; - -import type {Ref, PropsWithChildren} from 'react'; import {useMapsEventListener} from '../hooks/use-maps-event-listener'; import {usePropBinding} from '../hooks/use-prop-binding'; import {useDomEventListener} from '../hooks/use-dom-event-listener'; import {globalStyleManager} from '../libraries/global-style-manager'; +import {isVersionGreaterEqual} from '../libraries/version-utils'; export interface AdvancedMarkerContextValue { marker: google.maps.marker.AdvancedMarkerElement; } -export function isAdvancedMarker( - marker: google.maps.Marker | google.maps.marker.AdvancedMarkerElement -): marker is google.maps.marker.AdvancedMarkerElement { - return ( - (marker as google.maps.marker.AdvancedMarkerElement).content !== undefined - ); -} - -function isElementNode(node: Node): node is HTMLElement { - return node.nodeType === Node.ELEMENT_NODE; -} - /** * Copy of the `google.maps.CollisionBehavior` constants. * They have to be duplicated here since we can't wait for the maps API to load to be able to use them. @@ -101,6 +89,14 @@ export type AdvancedMarkerProps = PropsWithChildren< * For example in percent ("50%") or in pixels ("20px"). */ anchorPoint?: AdvancedMarkerAnchorPoint | [string, string]; + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%50". + */ + anchorLeft?: string; + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%100". + */ + anchorTop?: string; /** * A className for the content element. * (can only be used with HTML Marker content) @@ -119,6 +115,63 @@ type MarkerContentProps = PropsWithChildren & { anchorPoint?: AdvancedMarkerAnchorPoint | [string, string]; }; +export const AdvancedMarker = forwardRef( + (props: AdvancedMarkerProps, ref: Ref) => { + const {children, style, className, anchorPoint} = props; + const [marker, contentContainer] = useAdvancedMarker(props); + + const advancedMarkerContextValue: AdvancedMarkerContextValue | null = + useMemo(() => (marker ? {marker} : null), [marker]); + + useImperativeHandle( + ref, + () => marker as google.maps.marker.AdvancedMarkerElement, + [marker] + ); + + if (!contentContainer) return null; + + return ( + + {createPortal( + + {children} + , + contentContainer + )} + + ); + } +); + +AdvancedMarker.displayName = 'AdvancedMarker'; + +export function useAdvancedMarkerRef() { + const [marker, setMarker] = + useState(null); + + const refCallback = useCallback((m: AdvancedMarkerRef | null) => { + setMarker(m); + }, []); + + return [refCallback, marker] as const; +} + +export function isAdvancedMarker( + marker: google.maps.Marker | google.maps.marker.AdvancedMarkerElement +): marker is google.maps.marker.AdvancedMarkerElement { + return ( + (marker as google.maps.marker.AdvancedMarkerElement).content !== undefined + ); +} + +function isElementNode(node: Node): node is HTMLElement { + return node.nodeType === Node.ELEMENT_NODE; +} + const MarkerContent = ({children, styles, className}: MarkerContentProps) => { /* AdvancedMarker div that user can give styles and classes */ return ( @@ -131,6 +184,7 @@ const MarkerContent = ({children, styles, className}: MarkerContentProps) => { export type CustomMarkerContent = HTMLDivElement | null; export type AdvancedMarkerRef = google.maps.marker.AdvancedMarkerElement | null; + function useAdvancedMarker(props: AdvancedMarkerProps) { const [marker, setMarker] = useState(null); @@ -155,14 +209,13 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { position, title, zIndex, - anchorPoint + anchorPoint, + anchorLeft, + anchorTop } = props; const numChildren = Children.count(children); - const [xTranslation, yTranslation] = - anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; - // create an AdvancedMarkerElement instance and add it to the map once available useEffect(() => { if (!map || !markerLibrary) return; @@ -182,22 +235,6 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { // to target it via CSS to disable pointer event when using custom anchor point newMarker.dataset.origin = 'rgm'; - let xTranslationFlipped = `-${xTranslation}`; - let yTranslationFlipped = `-${yTranslation}`; - - if (xTranslation.trimStart().startsWith('-')) { - xTranslationFlipped = xTranslation.substring(1); - } - if (yTranslation.trimStart().startsWith('-')) { - yTranslationFlipped = yTranslation.substring(1); - } - - // The "translate(50%, 100%)" is here to counter and reset the default anchoring of the advanced marker element - // that comes from the api - const transformStyle = `translate(50%, 100%) translate(${xTranslationFlipped}, ${yTranslationFlipped})`; - contentElement.style.transform = transformStyle; - globalStyleManager.addAdvancedMarkerPointerEventsOverwrite(); - newMarker.content = contentElement; setContentContainer(contentElement); } @@ -208,7 +245,7 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { setMarker(null); setContentContainer(null); }; - }, [xTranslation, yTranslation, map, markerLibrary, numChildren]); + }, [map, markerLibrary, numChildren]); // When no children are present we don't have our own wrapper div // which usually gets the user provided className. In this case @@ -221,6 +258,14 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { marker.content.className = className ?? ''; }, [marker, className, numChildren]); + useAdvancedMarkerAnchorPoint( + marker, + anchorPoint, + anchorLeft, + anchorTop, + numChildren > 0 + ); + // copy other props usePropBinding(marker, 'position', position); usePropBinding(marker, 'title', title ?? ''); @@ -277,47 +322,64 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { return [marker, contentContainer] as const; } -export const AdvancedMarker = forwardRef( - (props: AdvancedMarkerProps, ref: Ref) => { - const {children, style, className, anchorPoint} = props; - const [marker, contentContainer] = useAdvancedMarker(props); - - const advancedMarkerContextValue: AdvancedMarkerContextValue | null = - useMemo(() => (marker ? {marker} : null), [marker]); - - useImperativeHandle( - ref, - () => marker as google.maps.marker.AdvancedMarkerElement, - [marker] - ); +function useAdvancedMarkerAnchorPoint( + marker: google.maps.marker.AdvancedMarkerElement | null, + anchorPoint: AdvancedMarkerAnchorPoint | [string, string] | undefined, + anchorLeft: string | undefined, + anchorTop: string | undefined, + hasChildren: boolean +) { + useEffect(() => { + if (!marker || !hasChildren) return; + + // The anchorLeft and anchorTop options are available since version 3.62.9c + // With the release of 3.65 (~May 2026) there will no longer be a version + // that doesn't support it. + const anchorOptionsSupported = isVersionGreaterEqual(3, 62); + const contentElement = marker.content; + if (!contentElement || !isElementNode(contentElement)) return; + + if (anchorLeft !== undefined || anchorTop !== undefined) { + if (!anchorOptionsSupported) { + console.warn( + 'AdvancedMarker: The anchorLeft and anchorTop props are only supported ' + + 'in Google Maps API version 3.62 and above. ' + + `The current version is ${google.maps.version}.` + ); + } - if (!contentContainer) return null; + marker.anchorLeft = anchorLeft; + marker.anchorTop = anchorTop; - return ( - - {createPortal( - - {children} - , - contentContainer - )} - - ); - } -); + // when anchorLeft and/or anchorTop are set, we'll ignore the anchorPoint + if (anchorPoint !== undefined) { + console.warn( + 'AdvancedMarker: the anchorPoint prop is ignored when anchorLeft ' + + 'and/or anchorTop are set.' + ); + } + return; + } -AdvancedMarker.displayName = 'AdvancedMarker'; + if (anchorPoint !== undefined) { + const [x, y] = anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; + const translateX = `calc(-1 * ${x})`; + const translateY = `calc(-1 * ${y})`; -export function useAdvancedMarkerRef() { - const [marker, setMarker] = - useState(null); + if (anchorOptionsSupported) { + // implement anchorPoint using the new anchorLeft and anchorTop options + marker.anchorLeft = translateX; + marker.anchorTop = translateY; - const refCallback = useCallback((m: AdvancedMarkerRef | null) => { - setMarker(m); - }, []); + // reset transform from legacy implementation + contentElement.style.transform = ''; + } else { + // The "translate(50%, 100%)" counters and resets the default + // anchoring of the advanced marker element from the api + contentElement.style.transform = `translate(50%, 100%) translate(${translateX}, ${translateY})`; - return [refCallback, marker] as const; + globalStyleManager.addAdvancedMarkerPointerEventsOverwrite(); + } + } + }, [marker, anchorPoint, anchorLeft, anchorTop, hasChildren]); } diff --git a/src/libraries/version-utils.ts b/src/libraries/version-utils.ts new file mode 100644 index 00000000..bb9fe226 --- /dev/null +++ b/src/libraries/version-utils.ts @@ -0,0 +1,15 @@ +export function isVersionGreaterEqual( + major: number, + minor: number +): boolean | undefined { + if (!google?.maps?.version) return undefined; + + const version = google.maps.version.split('.'); + + const currentMajor = parseInt(version[0], 10); + const currentMinor = parseInt(version[1], 10); + + return ( + currentMajor > major || (currentMajor === major && currentMinor >= minor) + ); +} diff --git a/types/google.maps.d.ts b/types/google.maps.d.ts index eb86797b..059128e5 100644 --- a/types/google.maps.d.ts +++ b/types/google.maps.d.ts @@ -14,4 +14,28 @@ declare namespace google.maps { */ internalUsageAttributionIds?: Iterable | null; } -} + + namespace marker { + interface AdvancedMarkerElementOptions { + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%50". + */ + anchorLeft?: string; + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%100". + */ + anchorTop?: string; + } + + interface AdvancedMarkerElement { + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%50". + */ + anchorLeft?: string; + /** + * A CSS length-percentage value which is used to offset the anchor point of the marker from the top left corner of the marker. This is useful when using a visual which has an anchor point that is different than the typical bottom center point of the default marker. The default value is "-%100". + */ + anchorTop?: string; + } + } +} \ No newline at end of file From 67628c671012c69d00457081ffc1f5e499c32c89 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Mon, 20 Oct 2025 19:58:13 +0200 Subject: [PATCH 3/5] test(advanced-marker): add anchor prop tests Adds a suite of tests for the anchor-related props ('anchorLeft', 'anchorTop', and 'anchorPoint') on the AdvancedMarker component. This suite covers: - Precedence of 'anchorLeft'/'anchorTop' over 'anchorPoint' on modern APIs. - Correct fallback to 'anchorPoint' on modern APIs. - Correct application of 'anchorPoint' via CSS transform on legacy APIs. - Warning generation when using modern props on legacy APIs. - Snapshot testing for console warnings. --- .../advanced-marker.test.tsx.snap | 17 ++++ .../__tests__/advanced-marker.test.tsx | 93 ++++++++++++++++++- src/components/advanced-marker.tsx | 7 +- 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 src/components/__tests__/__snapshots__/advanced-marker.test.tsx.snap diff --git a/src/components/__tests__/__snapshots__/advanced-marker.test.tsx.snap b/src/components/__tests__/__snapshots__/advanced-marker.test.tsx.snap new file mode 100644 index 00000000..b05367f3 --- /dev/null +++ b/src/components/__tests__/__snapshots__/advanced-marker.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`map and marker-library loaded anchoring with legacy API should warn when using anchorLeft/Top 1`] = ` +[ + [ + "AdvancedMarker: The anchorLeft and anchorTop props are only supported in Google Maps API version 3.62 and above. The current version is 3.61.0.", + ], +] +`; + +exports[`map and marker-library loaded anchoring with modern API anchorLeft/anchorTop should have precedence over anchorPoint 1`] = ` +[ + [ + "AdvancedMarker: the anchorPoint prop is ignored when anchorLeft and/or anchorTop are set.", + ], +] +`; diff --git a/src/components/__tests__/advanced-marker.test.tsx b/src/components/__tests__/advanced-marker.test.tsx index 84013cc2..17604e53 100644 --- a/src/components/__tests__/advanced-marker.test.tsx +++ b/src/components/__tests__/advanced-marker.test.tsx @@ -4,7 +4,7 @@ import {initialize, mockInstances} from '@googlemaps/jest-mocks'; import {cleanup, queryByTestId, render} from '@testing-library/react'; import '@testing-library/jest-dom'; -import {AdvancedMarker} from '../advanced-marker'; +import {AdvancedMarker, AdvancedMarkerAnchorPoint} from '../advanced-marker'; import {useMap} from '../../hooks/use-map'; import {useMapsLibrary} from '../../hooks/use-maps-library'; @@ -162,4 +162,95 @@ describe('map and marker-library loaded', () => { test.todo('marker should work with options'); test.todo('marker should have a click listener'); + + describe('anchoring with modern API', () => { + beforeEach(() => { + google.maps.version = '3.62.9'; + }); + + test('anchorLeft/anchorTop should have precedence over anchorPoint', async () => { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + render( + +
+ + ); + const marker = await waitForMockInstance( + google.maps.marker.AdvancedMarkerElement + ); + + expect(marker.anchorLeft).toBe('10px'); + expect(marker.anchorTop).toBe('20px'); + expect(consoleWarnSpy.mock.calls).toMatchSnapshot(); + + consoleWarnSpy.mockRestore(); + }); + + test('anchorPoint should be used as fallback', async () => { + render( + +
+ + ); + const marker = await waitForMockInstance( + google.maps.marker.AdvancedMarkerElement + ); + + expect(marker.anchorLeft).toBe('calc(-1 * 12%)'); + expect(marker.anchorTop).toBe('calc(-1 * 34%)'); + }); + }); + + describe('anchoring with legacy API', () => { + beforeEach(() => { + google.maps.version = '3.61.0'; + }); + + test('anchorPoint is applied as css transform', async () => { + render( + +
+ + ); + const marker = await waitForMockInstance( + google.maps.marker.AdvancedMarkerElement + ); + + expect(marker.content).toBeInstanceOf(HTMLElement); + expect((marker.content as HTMLElement).style.transform).toBe( + 'translate(50%, 100%) translate(calc(-1 * 50%), calc(-1 * 50%))' + ); + }); + + test('should warn when using anchorLeft/Top', async () => { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + render( + +
+ + ); + await waitForMockInstance(google.maps.marker.AdvancedMarkerElement); + + expect(consoleWarnSpy.mock.calls).toMatchSnapshot(); + + consoleWarnSpy.mockRestore(); + }); + }); }); diff --git a/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index 591f4173..b795620d 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -258,7 +258,7 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { marker.content.className = className ?? ''; }, [marker, className, numChildren]); - useAdvancedMarkerAnchorPoint( + useAdvancedMarkerAnchoring( marker, anchorPoint, anchorLeft, @@ -322,7 +322,7 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { return [marker, contentContainer] as const; } -function useAdvancedMarkerAnchorPoint( +function useAdvancedMarkerAnchoring( marker: google.maps.marker.AdvancedMarkerElement | null, anchorPoint: AdvancedMarkerAnchorPoint | [string, string] | undefined, anchorLeft: string | undefined, @@ -363,6 +363,9 @@ function useAdvancedMarkerAnchorPoint( if (anchorPoint !== undefined) { const [x, y] = anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; + + // NOTE: since x and y can theoretically be any valid CSS length-percentage + // value, we need to use calc() to negate them. const translateX = `calc(-1 * ${x})`; const translateY = `calc(-1 * ${y})`; From 5b99c8a36e87a0145b6f5c4950ea65e73f928482 Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Mon, 20 Oct 2025 21:26:48 +0200 Subject: [PATCH 4/5] feat(advanced-marker): deprecate anchorPoint prop Marks the 'anchorPoint' prop as deprecated in favor of the 'anchorLeft' and 'anchorTop' props. - Adds a '@deprecated' JSDoc tag to the 'anchorPoint' prop. - Adds a TODO comment to add a console warning in a future version. --- src/components/advanced-marker.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index b795620d..5fe394a9 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -81,6 +81,8 @@ export type AdvancedMarkerProps = PropsWithChildren< clickable?: boolean; collisionBehavior?: CollisionBehavior; /** + * @deprecated Use `anchorLeft` and `anchorTop` instead. + * * The anchor point for the Advanced Marker. * Either use one of the predefined anchor point from the "AdvancedMarkerAnchorPoint" export * or provide a string tuple in the form of ["xPosition", "yPosition"]. @@ -362,6 +364,7 @@ function useAdvancedMarkerAnchoring( } if (anchorPoint !== undefined) { + // TODO: add console.warn in a future version to inform about deprecation const [x, y] = anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; // NOTE: since x and y can theoretically be any valid CSS length-percentage From 9db18d86c01307dbb093af301688ed8bdf3be72d Mon Sep 17 00:00:00 2001 From: Martin Schuhfuss Date: Tue, 21 Oct 2025 10:12:31 +0200 Subject: [PATCH 5/5] fix: restrict event-handling hacks to custom anchoring --- src/components/advanced-marker.tsx | 12 ++++++------ src/components/info-window.tsx | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/advanced-marker.tsx b/src/components/advanced-marker.tsx index 5fe394a9..5ec39c82 100644 --- a/src/components/advanced-marker.tsx +++ b/src/components/advanced-marker.tsx @@ -232,11 +232,6 @@ function useAdvancedMarker(props: AdvancedMarkerProps) { if (numChildren > 0) { contentElement = document.createElement('div'); - // We need some kind of flag to identify the custom marker content - // in the infowindow component. Choosing a data attribute to also be able - // to target it via CSS to disable pointer event when using custom anchor point - newMarker.dataset.origin = 'rgm'; - newMarker.content = contentElement; setContentContainer(contentElement); } @@ -367,7 +362,7 @@ function useAdvancedMarkerAnchoring( // TODO: add console.warn in a future version to inform about deprecation const [x, y] = anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM']; - // NOTE: since x and y can theoretically be any valid CSS length-percentage + // NOTE: since x and y can be any valid CSS length-percentage // value, we need to use calc() to negate them. const translateX = `calc(-1 * ${x})`; const translateY = `calc(-1 * ${y})`; @@ -384,6 +379,11 @@ function useAdvancedMarkerAnchoring( // anchoring of the advanced marker element from the api contentElement.style.transform = `translate(50%, 100%) translate(${translateX}, ${translateY})`; + // We need some kind of flag to identify the custom marker content + // in the infowindow component. Choosing a data attribute to also be able + // to target it via CSS to disable pointer event when using custom anchor point + marker.dataset.origin = 'rgm'; + globalStyleManager.addAdvancedMarkerPointerEventsOverwrite(); } } diff --git a/src/components/info-window.tsx b/src/components/info-window.tsx index 38eeee9b..c354cf8d 100644 --- a/src/components/info-window.tsx +++ b/src/components/info-window.tsx @@ -189,7 +189,8 @@ export const InfoWindow: FunctionComponent< const anchorBcr = anchor?.getBoundingClientRect(); // This checks whether or not the anchor has custom content with our own - // div wrapper. If not, that means we have a regular AdvancedMarker without any children. + // div wrapper. If not, that means we have a regular AdvancedMarker without + // children, or an AdvancedMarker that uses the anchorLeft/anchorTop props. // In that case we do not want to adjust the infowindow since it is all handled correctly // by the Google Maps API. if (anchorBcr && anchor.dataset.origin === 'rgm') {