Skip to content

Commit 1429944

Browse files
committed
feat(google-maps): OverlayView defaultOpen prop and LatLng position widening
Two additive changes to <ScriptGoogleMapsOverlayView>: 1. Add `defaultOpen` prop for the uncontrolled mode. The overlay still opens on mount by default; pass `:default-open="false"` to start closed without taking control of the open state via v-model. Bound v-model:open continues to take precedence. 2. Widen the `position` prop to accept `google.maps.LatLng | LatLngLiteral` so values returned by Maps APIs (e.g. `resolveQueryToLatLng`, marker positions) can be passed straight through without manual conversion. Implementation notes: - Refactored to reactive prop destructure with inline defaults (matching the project preference); withDefaults removed - defineModel kept for the `open` model so its `default: undefined` opts out of Vue's boolean prop coercion (which would otherwise turn an unset `open` into `false` and break the uncontrolled default) - Added `normalizeLatLng` helper in `useGoogleMapsResource` that detects callable `.lat`/`.lng` accessors instead of relying on `instanceof google.maps.LatLng` (mocks in tests return plain objects) - The `props.position` watcher source is normalized so the watch identity is stable for both LatLng instances and literals - Inline default for `defaultOpen` is `true`, preserving v0 behaviour Docs: added "Controlled vs Uncontrolled Open State" and "Position Format" sections to overlay-view.md. Tests: new file `test/nuxt-runtime/google-maps-overlay-view.nuxt.test.ts` covering the uncontrolled default, defaultOpen=false, controlled explicit :open prop, and both position shapes via mountSuspended with a minimal mock OverlayView base class. Plus pure-helper tests for normalizeLatLng.
1 parent b96ce41 commit 1429944

4 files changed

Lines changed: 405 additions & 48 deletions

File tree

docs/content/scripts/google-maps/2.api/11.overlay-view.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,45 @@ When nested inside a `ScriptGoogleMapsMarker`, the overlay automatically inherit
3131
</template>
3232
```
3333

34+
## Controlled vs Uncontrolled Open State
35+
36+
The overlay supports two open-state patterns: uncontrolled (component-managed) and controlled (parent-managed via `v-model:open`).
37+
38+
**Uncontrolled.** The component owns its open state. The overlay opens by default; pass `:default-open="false"` to start closed. This is the simplest pattern when you don't need to react to state changes from the parent.
39+
40+
```vue
41+
<template>
42+
<!-- Opens immediately on mount (default) -->
43+
<ScriptGoogleMapsOverlayView :position="{ lat: -34.397, lng: 150.644 }">
44+
<div>Always-open label</div>
45+
</ScriptGoogleMapsOverlayView>
46+
47+
<!-- Starts closed; toggle later via a template ref or marker click -->
48+
<ScriptGoogleMapsOverlayView
49+
:position="{ lat: -34.397, lng: 150.644 }"
50+
:default-open="false"
51+
>
52+
<div>Initially hidden</div>
53+
</ScriptGoogleMapsOverlayView>
54+
</template>
55+
```
56+
57+
**Controlled.** Bind `v-model:open` to a parent ref. The parent owns the state and the overlay reflects it. The overlay updates the bound ref when something internal flips it (for example, the marker-cluster auto-hide behaviour).
58+
59+
```vue
60+
<script setup lang="ts">
61+
const open = ref(true)
62+
</script>
63+
64+
<template>
65+
<ScriptGoogleMapsOverlayView v-model:open="open" :position="{ lat: -34.397, lng: 150.644 }">
66+
<div>Controlled by parent</div>
67+
</ScriptGoogleMapsOverlayView>
68+
</template>
69+
```
70+
71+
When `v-model:open` is bound, `defaultOpen` has no effect; pass an initial value to the bound ref instead.
72+
3473
## Popup on Marker Click
3574

3675
Using `v-model:open` keeps the overlay mounted, toggling visibility via CSS. This avoids remount cost and preserves internal state.
@@ -94,6 +133,33 @@ For simple cases where remounting is acceptable, `v-if` also works:
94133
</template>
95134
```
96135

136+
## Position Format
137+
138+
The `position` prop accepts either a plain `LatLngLiteral` (`{ lat, lng }`) or a `google.maps.LatLng` instance, so you can pass values straight from the Maps API without converting them first.
139+
140+
```vue
141+
<script setup lang="ts">
142+
const mapRef = ref()
143+
144+
async function showSydney() {
145+
// Resolve a query into a LatLng-shaped value via the Maps API
146+
const sydney = await mapRef.value?.resolveQueryToLatLng('Sydney, Australia')
147+
// Pass it through unchanged: works for both shapes
148+
position.value = sydney
149+
}
150+
151+
const position = ref()
152+
</script>
153+
154+
<template>
155+
<ScriptGoogleMaps ref="mapRef" api-key="your-api-key">
156+
<ScriptGoogleMapsOverlayView v-if="position" :position="position">
157+
<div>Resolved position</div>
158+
</ScriptGoogleMapsOverlayView>
159+
</ScriptGoogleMaps>
160+
</template>
161+
```
162+
97163
## Map Panning
98164

99165
When an overlay opens, the map automatically pans so the overlay is fully visible, matching the native `InfoWindow` behavior. The default padding is 40px from the map edge.

packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue

Lines changed: 76 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,21 @@ export type ScriptGoogleMapsOverlayPane = 'mapPane' | 'overlayLayer' | 'markerLa
1111
export interface ScriptGoogleMapsOverlayViewProps {
1212
/**
1313
* Geographic position for the overlay. Falls back to parent marker position if omitted.
14+
*
15+
* Accepts either a plain `LatLngLiteral` (`{ lat, lng }`) or a
16+
* `google.maps.LatLng` instance.
1417
* @see https://developers.google.com/maps/documentation/javascript/reference/overlay-view#OverlayView
1518
*/
16-
position?: google.maps.LatLngLiteral
19+
position?: google.maps.LatLng | google.maps.LatLngLiteral
20+
/**
21+
* Initial open state for the uncontrolled mode (when `v-model:open` is not
22+
* bound). When omitted, the overlay opens on mount, matching v0 behaviour.
23+
*
24+
* Has no effect when `v-model:open` is used; pass an initial value to the
25+
* bound ref instead.
26+
* @default true
27+
*/
28+
defaultOpen?: boolean
1729
/**
1830
* Anchor point of the overlay relative to its position.
1931
* @default 'bottom-center'
@@ -80,43 +92,58 @@ export interface ScriptGoogleMapsOverlayViewExpose {
8092
<script setup lang="ts">
8193
import { computed, inject, ref, useTemplateRef, watch } from 'vue'
8294
import { MARKER_CLUSTERER_INJECTION_KEY } from './types'
83-
import { defineDeprecatedAlias, MARKER_INJECTION_KEY, useGoogleMapsResource } from './useGoogleMapsResource'
95+
import { defineDeprecatedAlias, MARKER_INJECTION_KEY, normalizeLatLng, useGoogleMapsResource } from './useGoogleMapsResource'
8496
8597
defineOptions({
8698
inheritAttrs: false,
8799
})
88100
89-
const props = withDefaults(defineProps<ScriptGoogleMapsOverlayViewProps>(), {
90-
anchor: 'bottom-center',
91-
pane: 'floatPane',
92-
blockMapInteraction: true,
93-
panOnOpen: true,
94-
hideWhenClustered: true,
95-
})
96-
97-
defineEmits<ScriptGoogleMapsOverlayViewEmits>()
101+
const {
102+
position,
103+
defaultOpen = true,
104+
anchor = 'bottom-center',
105+
offset,
106+
pane = 'floatPane',
107+
zIndex,
108+
blockMapInteraction = true,
109+
panOnOpen = true,
110+
hideWhenClustered = true,
111+
} = defineProps<ScriptGoogleMapsOverlayViewProps>()
98112
99113
defineSlots<ScriptGoogleMapsOverlayViewSlots>()
100114
115+
// Controlled vs uncontrolled open state.
116+
// - When the parent binds `v-model:open`, `open` becomes a controlled
117+
// model that writes through `emit('update:open', value)`.
118+
// - When the parent omits it, `open` is a local ref managed by the
119+
// component, seeded from `defaultOpen` (which defaults to `true`,
120+
// preserving v0 behaviour where the overlay opens on mount).
121+
//
122+
// `defineModel` is used here (rather than reactive prop destructure) because
123+
// it accepts `default: undefined`, which opts out of Vue's boolean prop
124+
// coercion that would otherwise turn an unset `open` into `false`. We then
125+
// seed the local default below if the model is uncontrolled.
101126
const open = defineModel<boolean>('open', { default: undefined })
127+
if (open.value === undefined)
128+
open.value = defaultOpen ?? true
102129
103130
const markerContext = inject(MARKER_INJECTION_KEY, undefined)
104131
const markerClustererContext = inject(MARKER_CLUSTERER_INJECTION_KEY, undefined)
105132
106-
// Read position fresh each call — NOT a computed, because Google Maps object
107-
// internal state (marker.getPosition()) is invisible to Vue's reactivity.
108-
// A computed would cache stale coordinates after marker drag.
133+
// Read position fresh each call: NOT a computed, because Google Maps object
134+
// internal state (marker.getPosition()) is invisible to Vue's reactivity. A
135+
// computed would cache stale coordinates after marker drag.
136+
//
137+
// `position` may be either a `LatLngLiteral` or a `google.maps.LatLng` instance,
138+
// so we normalize through `normalizeLatLng` (which checks for callable `.lat`
139+
// rather than relying on `instanceof`, since mocked APIs in tests return plain
140+
// objects).
109141
function getResolvedPosition(): google.maps.LatLngLiteral | undefined {
110-
if (props.position)
111-
return props.position
112-
if (markerContext?.advancedMarkerElement.value) {
113-
const markerPosition = markerContext.advancedMarkerElement.value.position
114-
if (markerPosition) {
115-
return markerPosition instanceof google.maps.LatLng
116-
? { lat: markerPosition.lat(), lng: markerPosition.lng() }
117-
: { lat: markerPosition.lat, lng: markerPosition.lng }
118-
}
119-
}
142+
if (position)
143+
return normalizeLatLng(position)
144+
const markerPosition = markerContext?.advancedMarkerElement.value?.position
145+
if (markerPosition)
146+
return normalizeLatLng(markerPosition)
120147
return undefined
121148
}
122149
@@ -180,21 +207,21 @@ function panMapToFitOverlay(el: HTMLElement, map: google.maps.Map, padding: numb
180207
const overlay = useGoogleMapsResource<google.maps.OverlayView>({
181208
// ready condition accesses .value on ShallowRefs — tracked by whenever() in useGoogleMapsResource
182209
ready: () => !!overlayContent.value
183-
&& !!(props.position || markerContext?.advancedMarkerElement.value),
210+
&& !!(position || markerContext?.advancedMarkerElement.value),
184211
create({ mapsApi, map }) {
185212
const el = overlayContent.value!
186213
187214
class CustomOverlay extends mapsApi.OverlayView {
188215
override onAdd() {
189216
const panes = this.getPanes()
190217
if (panes) {
191-
panes[props.pane].appendChild(el)
192-
if (props.blockMapInteraction)
218+
panes[pane].appendChild(el)
219+
if (blockMapInteraction)
193220
mapsApi.OverlayView.preventMapHitsAndGesturesFrom(el)
194221
}
195-
if (props.panOnOpen) {
222+
if (panOnOpen) {
196223
// Wait for draw() to position the element, then pan
197-
const padding = typeof props.panOnOpen === 'number' ? props.panOnOpen : 40
224+
const padding = typeof panOnOpen === 'number' ? panOnOpen : 40
198225
requestAnimationFrame(() => {
199226
panMapToFitOverlay(el, map, padding)
200227
})
@@ -209,8 +236,8 @@ const overlay = useGoogleMapsResource<google.maps.OverlayView>({
209236
return
210237
}
211238
212-
const position = getResolvedPosition()
213-
if (!position) {
239+
const resolvedPosition = getResolvedPosition()
240+
if (!resolvedPosition) {
214241
isPositioned.value = false
215242
hideElement(el)
216243
return
@@ -222,7 +249,7 @@ const overlay = useGoogleMapsResource<google.maps.OverlayView>({
222249
return
223250
}
224251
const pos = projection.fromLatLngToDivPixel(
225-
new mapsApi.LatLng(position.lat, position.lng),
252+
new mapsApi.LatLng(resolvedPosition.lat, resolvedPosition.lng),
226253
)
227254
if (!pos) {
228255
isPositioned.value = false
@@ -231,11 +258,11 @@ const overlay = useGoogleMapsResource<google.maps.OverlayView>({
231258
}
232259
233260
el.style.position = 'absolute'
234-
el.style.left = `${pos.x + (props.offset?.x ?? 0)}px`
235-
el.style.top = `${pos.y + (props.offset?.y ?? 0)}px`
236-
el.style.transform = ANCHOR_TRANSFORMS[props.anchor]
237-
if (props.zIndex !== undefined)
238-
el.style.zIndex = String(props.zIndex)
261+
el.style.left = `${pos.x + (offset?.x ?? 0)}px`
262+
el.style.top = `${pos.y + (offset?.y ?? 0)}px`
263+
el.style.transform = ANCHOR_TRANSFORMS[anchor]
264+
if (zIndex !== undefined)
265+
el.style.zIndex = String(zIndex)
239266
el.style.visibility = 'visible'
240267
el.style.pointerEvents = 'auto'
241268
setDataState(el, 'open')
@@ -279,32 +306,33 @@ if (markerContext) {
279306
watch(
280307
() => {
281308
const markerPosition = markerContext.advancedMarkerElement.value?.position
282-
if (!markerPosition)
283-
return undefined
284-
return markerPosition instanceof google.maps.LatLng
285-
? { lat: markerPosition.lat(), lng: markerPosition.lng() }
286-
: { lat: markerPosition.lat, lng: markerPosition.lng }
309+
return markerPosition ? normalizeLatLng(markerPosition) : undefined
287310
},
288311
() => { overlay.value?.draw() },
289312
)
290313
}
291314
292-
// Reposition on prop changes (draw() is designed to be called repeatedly)
293-
// Only watches explicit props — marker position changes are handled by event listeners above
315+
// Reposition on prop changes (draw() is designed to be called repeatedly).
316+
// Only watches explicit props; marker position changes are handled by the
317+
// listeners above. `position` is normalized so that callable-coordinate
318+
// LatLng instances produce a stable identity in the watch source.
294319
watch(
295-
() => [props.position?.lat, props.position?.lng, props.offset?.x, props.offset?.y, props.zIndex, props.anchor],
320+
() => {
321+
const p = position ? normalizeLatLng(position) : undefined
322+
return [p?.lat, p?.lng, offset?.x, offset?.y, zIndex, anchor]
323+
},
296324
() => { overlay.value?.draw() },
297325
)
298326
299-
// v-model:open — toggle visibility without remounting the overlay
327+
// Toggle visibility without remounting the overlay when `open` changes.
300328
watch(() => open.value, () => {
301329
if (!overlay.value)
302330
return
303331
overlay.value.draw()
304332
})
305333
306334
// Pane or blockMapInteraction change requires remount (setMap cycles onRemove + onAdd + draw)
307-
watch([() => props.pane, () => props.blockMapInteraction], () => {
335+
watch([() => pane, () => blockMapInteraction], () => {
308336
if (overlay.value) {
309337
const map = overlay.value.getMap()
310338
overlay.value.setMap(null)
@@ -318,7 +346,7 @@ if (markerClustererContext && markerContext) {
318346
watch(
319347
() => markerClustererContext.clusteringVersion.value,
320348
() => {
321-
if (!props.hideWhenClustered || open.value === false)
349+
if (!hideWhenClustered || open.value === false)
322350
return
323351
const clusterer = markerClustererContext.markerClusterer.value as any
324352
if (!clusterer?.clusters)

packages/script/src/runtime/components/GoogleMaps/useGoogleMapsResource.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@ export interface GoogleMapsResourceContext {
4141
mapsApi: typeof google.maps
4242
}
4343

44+
/**
45+
* Normalizes a `LatLng | LatLngLiteral` value into a plain `LatLngLiteral`.
46+
*
47+
* Google's `LatLng` exposes coordinates via `.lat()`/`.lng()` methods, while
48+
* `LatLngLiteral` exposes them as plain `lat`/`lng` numeric properties. The
49+
* runtime distinguishes them by checking whether `.lat` is callable; this is
50+
* preferred over `instanceof google.maps.LatLng` because mocked APIs in tests
51+
* return plain objects rather than real `LatLng` instances.
52+
*/
53+
export function normalizeLatLng(
54+
p: google.maps.LatLng | google.maps.LatLngLiteral,
55+
): google.maps.LatLngLiteral {
56+
if (typeof p.lat === 'function') {
57+
const ll = p as google.maps.LatLng
58+
return { lat: ll.lat(), lng: ll.lng() }
59+
}
60+
return { lat: p.lat as number, lng: p.lng as number }
61+
}
62+
4463
/**
4564
* Defines a deprecated property alias on an exposed object. Reading the alias
4665
* returns the value of the canonical key and emits a one-shot

0 commit comments

Comments
 (0)