Skip to content

Commit d7b128e

Browse files
feat: updated AdvancedMarker anchoring implementation (#841)
* fix: make pointer events more robust * 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. * 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. * 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. * fix: restrict event-handling hacks to custom anchoring --------- Co-authored-by: Martin Schuhfuss <[email protected]>
1 parent af36b8f commit d7b128e

File tree

13 files changed

+442
-115
lines changed

13 files changed

+442
-115
lines changed

docs/api-reference/components/advanced-marker.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,25 @@ The position is measured from the top-left corner and
173173
can be anything that can be consumed by a CSS translate() function.
174174
For example in percent `[10%, 90%]` or in pixels `[10px, 20px]`.
175175

176+
#### `anchorLeft`: string
177+
178+
A [CSS length-percentage] value which is used to translate the marker
179+
content relative to the anchor point. A value of 0 means the anchor-point
180+
will be at the left edge of the content-element. The default value is `-%50`,
181+
so the anchor point will be at the center of the content element.
182+
You can also use CSS `calc()` expressions to combine percentage and pixel
183+
values.
184+
185+
#### `anchorTop`: string
186+
187+
A [CSS length-percentage] value which is used to translate the marker content
188+
relative to the anchor point. When this value is 0, the anchor-point will be
189+
at the top-edge of the content element. The default value is `-%100`, which
190+
places the anchor-point at the bottom edge. You can also use CSS `calc()`
191+
expressions to combine percentage and pixel values.
192+
193+
[CSS length-percentage]: https://developer.mozilla.org/en-US/docs/Web/CSS/length-percentage
194+
176195
### Other Props
177196

178197
#### `clickable`: boolean

examples/advanced-marker-interaction/src/app.tsx

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ import {
1313
CollisionBehavior
1414
} from '@vis.gl/react-google-maps';
1515

16-
import {getData} from './data';
17-
1816
import ControlPanel from './control-panel';
1917

18+
import {getData, MarkerType, textSnippets} from './data';
19+
2020
import './style.css';
2121

2222
export type AnchorPointName = keyof typeof AdvancedMarkerAnchorPoint;
@@ -27,7 +27,10 @@ export type AnchorPointName = keyof typeof AdvancedMarkerAnchorPoint;
2727
// thus appear in front.
2828
const data = getData()
2929
.sort((a, b) => b.position.lat - a.position.lat)
30-
.map((dataItem, index) => ({...dataItem, zIndex: index}));
30+
.map((dataItem, index) => ({
31+
...dataItem,
32+
zIndex: index
33+
}));
3134

3235
const Z_INDEX_SELECTED = data.length;
3336
const Z_INDEX_HOVER = data.length + 1;
@@ -40,22 +43,33 @@ const App = () => {
4043

4144
const [hoverId, setHoverId] = useState<string | null>(null);
4245
const [selectedId, setSelectedId] = useState<string | null>(null);
46+
const [infowindowContent, setInfowindowContent] = useState<string | null>(
47+
null
48+
);
4349

44-
const [anchorPoint, setAnchorPoint] = useState('BOTTOM' as AnchorPointName);
50+
const [anchorPoint, setAnchorPoint] = useState(
51+
'LEFT_CENTER' as AnchorPointName
52+
);
4553
const [selectedMarker, setSelectedMarker] =
4654
useState<google.maps.marker.AdvancedMarkerElement | null>(null);
4755
const [infoWindowShown, setInfoWindowShown] = useState(false);
4856

4957
const onMouseEnter = useCallback((id: string | null) => setHoverId(id), []);
5058
const onMouseLeave = useCallback(() => setHoverId(null), []);
5159
const onMarkerClick = useCallback(
52-
(id: string | null, marker?: google.maps.marker.AdvancedMarkerElement) => {
60+
(
61+
id: string | null,
62+
marker?: google.maps.marker.AdvancedMarkerElement,
63+
type?: MarkerType
64+
) => {
5365
setSelectedId(id);
5466

5567
if (marker) {
5668
setSelectedMarker(marker);
5769
}
5870

71+
setInfowindowContent(type ? textSnippets[type] : null);
72+
5973
if (id !== selectedId) {
6074
setInfoWindowShown(true);
6175
} else {
@@ -97,12 +111,25 @@ const App = () => {
97111
zIndex = Z_INDEX_SELECTED;
98112
}
99113

114+
if (type === 'default') {
115+
return (
116+
<AdvancedMarkerWithRef
117+
key={id}
118+
zIndex={zIndex}
119+
position={position}
120+
onMarkerClick={marker => onMarkerClick(id, marker, type)}
121+
onMouseEnter={() => onMouseEnter(id)}
122+
onMouseLeave={onMouseLeave}
123+
/>
124+
);
125+
}
126+
100127
if (type === 'pin') {
101128
return (
102129
<AdvancedMarkerWithRef
103130
onMarkerClick={(
104131
marker: google.maps.marker.AdvancedMarkerElement
105-
) => onMarkerClick(id, marker)}
132+
) => onMarkerClick(id, marker, type)}
106133
onMouseEnter={() => onMouseEnter(id)}
107134
onMouseLeave={onMouseLeave}
108135
key={id}
@@ -114,7 +141,7 @@ const App = () => {
114141
}}
115142
position={position}>
116143
<Pin
117-
background={selectedId === id ? '#22ccff' : null}
144+
background={selectedId === id ? '#22ccff' : 'orange'}
118145
borderColor={selectedId === id ? '#1e89a1' : null}
119146
glyphColor={selectedId === id ? '#0f677a' : null}
120147
/>
@@ -137,7 +164,9 @@ const App = () => {
137164
}}
138165
onMarkerClick={(
139166
marker: google.maps.marker.AdvancedMarkerElement
140-
) => onMarkerClick(id, marker)}
167+
) => {
168+
onMarkerClick(id, marker, type);
169+
}}
141170
onMouseEnter={() => onMouseEnter(id)}
142171
collisionBehavior={
143172
CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY
@@ -166,11 +195,12 @@ const App = () => {
166195

167196
{infoWindowShown && selectedMarker && (
168197
<InfoWindow
198+
headerDisabled={true}
169199
anchor={selectedMarker}
170200
pixelOffset={[0, -2]}
171201
onCloseClick={handleInfowindowCloseClick}>
172202
<h2>Marker {selectedId}</h2>
173-
<p>Some arbitrary html to be rendered into the InfoWindow.</p>
203+
<p>{infowindowContent}</p>
174204
</InfoWindow>
175205
)}
176206
</Map>

examples/advanced-marker-interaction/src/data.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
1+
export type MarkerType = 'default' | 'pin' | 'html';
2+
13
type MarkerData = Array<{
24
id: string;
35
position: google.maps.LatLngLiteral;
4-
type: 'pin' | 'html';
6+
type: MarkerType;
57
zIndex: number;
8+
infowindowContent?: string;
69
}>;
710

11+
export const textSnippets = {
12+
default: 'This is a default AdvancedMarkerElement without custom content',
13+
pin: 'This is a AdvancedMarkerElement with custom pin-style marker',
14+
html: 'This is a AdvancedMarkerElement with custom HTML content'
15+
} as const;
16+
817
export function getData() {
918
const data: MarkerData = [];
1019

1120
// create 50 random markers
1221
for (let index = 0; index < 50; index++) {
22+
const type =
23+
Math.random() < 0.1 ? 'default' : Math.random() < 0.5 ? 'pin' : 'html';
24+
1325
data.push({
1426
id: String(index),
1527
position: {lat: rnd(53.52, 53.63), lng: rnd(9.88, 10.12)},
1628
zIndex: index,
17-
type: Math.random() < 0.5 ? 'pin' : 'html'
29+
type
1830
});
1931
}
2032

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"baseUrl": ".",
5+
"paths": {
6+
"@vis.gl/react-google-maps": ["../../src"]
7+
}
8+
},
9+
"include": ["src/**/*", "../../src/**/*", "./types/**/*"],
10+
"exclude": ["node_modules", "dist"]
11+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export declare global {
2+
// const or let does not work in this case, it has to be var
3+
// eslint-disable-next-line no-var
4+
var GOOGLE_MAPS_API_KEY: string | undefined;
5+
// eslint-disable-next-line no-var, @typescript-eslint/no-explicit-any
6+
var process: any;
7+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`map and marker-library loaded anchoring with legacy API should warn when using anchorLeft/Top 1`] = `
4+
[
5+
[
6+
"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.",
7+
],
8+
]
9+
`;
10+
11+
exports[`map and marker-library loaded anchoring with modern API anchorLeft/anchorTop should have precedence over anchorPoint 1`] = `
12+
[
13+
[
14+
"AdvancedMarker: the anchorPoint prop is ignored when anchorLeft and/or anchorTop are set.",
15+
],
16+
]
17+
`;

src/components/__tests__/advanced-marker.test.tsx

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {initialize, mockInstances} from '@googlemaps/jest-mocks';
44
import {cleanup, queryByTestId, render} from '@testing-library/react';
55
import '@testing-library/jest-dom';
66

7-
import {AdvancedMarker} from '../advanced-marker';
7+
import {AdvancedMarker, AdvancedMarkerAnchorPoint} from '../advanced-marker';
88
import {useMap} from '../../hooks/use-map';
99
import {useMapsLibrary} from '../../hooks/use-maps-library';
1010

@@ -162,4 +162,95 @@ describe('map and marker-library loaded', () => {
162162

163163
test.todo('marker should work with options');
164164
test.todo('marker should have a click listener');
165+
166+
describe('anchoring with modern API', () => {
167+
beforeEach(() => {
168+
google.maps.version = '3.62.9';
169+
});
170+
171+
test('anchorLeft/anchorTop should have precedence over anchorPoint', async () => {
172+
const consoleWarnSpy = jest
173+
.spyOn(console, 'warn')
174+
.mockImplementation(() => {});
175+
176+
render(
177+
<AdvancedMarker
178+
position={{lat: 0, lng: 0}}
179+
anchorLeft={'10px'}
180+
anchorTop={'20px'}
181+
anchorPoint={['10%', '20%']}>
182+
<div />
183+
</AdvancedMarker>
184+
);
185+
const marker = await waitForMockInstance(
186+
google.maps.marker.AdvancedMarkerElement
187+
);
188+
189+
expect(marker.anchorLeft).toBe('10px');
190+
expect(marker.anchorTop).toBe('20px');
191+
expect(consoleWarnSpy.mock.calls).toMatchSnapshot();
192+
193+
consoleWarnSpy.mockRestore();
194+
});
195+
196+
test('anchorPoint should be used as fallback', async () => {
197+
render(
198+
<AdvancedMarker
199+
position={{lat: 0, lng: 0}}
200+
anchorPoint={['12%', '34%']}>
201+
<div />
202+
</AdvancedMarker>
203+
);
204+
const marker = await waitForMockInstance(
205+
google.maps.marker.AdvancedMarkerElement
206+
);
207+
208+
expect(marker.anchorLeft).toBe('calc(-1 * 12%)');
209+
expect(marker.anchorTop).toBe('calc(-1 * 34%)');
210+
});
211+
});
212+
213+
describe('anchoring with legacy API', () => {
214+
beforeEach(() => {
215+
google.maps.version = '3.61.0';
216+
});
217+
218+
test('anchorPoint is applied as css transform', async () => {
219+
render(
220+
<AdvancedMarker
221+
position={{lat: 0, lng: 0}}
222+
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}>
223+
<div />
224+
</AdvancedMarker>
225+
);
226+
const marker = await waitForMockInstance(
227+
google.maps.marker.AdvancedMarkerElement
228+
);
229+
230+
expect(marker.content).toBeInstanceOf(HTMLElement);
231+
expect((marker.content as HTMLElement).style.transform).toBe(
232+
'translate(50%, 100%) translate(calc(-1 * 50%), calc(-1 * 50%))'
233+
);
234+
});
235+
236+
test('should warn when using anchorLeft/Top', async () => {
237+
const consoleWarnSpy = jest
238+
.spyOn(console, 'warn')
239+
.mockImplementation(() => {});
240+
241+
render(
242+
<AdvancedMarker
243+
position={{lat: 0, lng: 0}}
244+
anchorLeft={'10px'}
245+
anchorTop={'20px'}>
246+
<div />
247+
</AdvancedMarker>
248+
);
249+
await waitForMockInstance(google.maps.marker.AdvancedMarkerElement);
250+
251+
expect(consoleWarnSpy.mock.calls).toMatchSnapshot();
252+
253+
consoleWarnSpy.mockRestore();
254+
});
255+
});
165256
});

0 commit comments

Comments
 (0)