Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/content/scripts/google-maps/2.api/11.overlay-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,45 @@ When nested inside a `ScriptGoogleMapsMarker`, the overlay automatically inherit
</template>
```

## Controlled vs Uncontrolled Open State

The overlay supports two open-state patterns: uncontrolled (component-managed) and controlled (parent-managed via `v-model:open`).

**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.

```vue
<template>
<!-- Opens immediately on mount (default) -->
<ScriptGoogleMapsOverlayView :position="{ lat: -34.397, lng: 150.644 }">
<div>Always-open label</div>
</ScriptGoogleMapsOverlayView>

<!-- Starts closed; toggle later via a template ref or marker click -->
<ScriptGoogleMapsOverlayView
:position="{ lat: -34.397, lng: 150.644 }"
:default-open="false"
>
<div>Initially hidden</div>
</ScriptGoogleMapsOverlayView>
</template>
```

**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).

```vue
<script setup lang="ts">
const open = ref(true)
</script>

<template>
<ScriptGoogleMapsOverlayView v-model:open="open" :position="{ lat: -34.397, lng: 150.644 }">
<div>Controlled by parent</div>
</ScriptGoogleMapsOverlayView>
</template>
```

When `v-model:open` is bound, `defaultOpen` has no effect; pass an initial value to the bound ref instead.

## Popup on Marker Click

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

## Position Format

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.

```vue
<script setup lang="ts">
const mapRef = ref()

async function showSydney() {
// Resolve a query into a LatLng-shaped value via the Maps API
const sydney = await mapRef.value?.resolveQueryToLatLng('Sydney, Australia')
// Pass it through unchanged: works for both shapes
position.value = sydney
}

const position = ref()
</script>

<template>
<ScriptGoogleMaps ref="mapRef" api-key="your-api-key">
<ScriptGoogleMapsOverlayView v-if="position" :position="position">
<div>Resolved position</div>
</ScriptGoogleMapsOverlayView>
</ScriptGoogleMaps>
</template>
```

## Map Panning

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,21 @@ export type ScriptGoogleMapsOverlayPane = 'mapPane' | 'overlayLayer' | 'markerLa
export interface ScriptGoogleMapsOverlayViewProps {
/**
* Geographic position for the overlay. Falls back to parent marker position if omitted.
*
* Accepts either a plain `LatLngLiteral` (`{ lat, lng }`) or a
* `google.maps.LatLng` instance.
* @see https://developers.google.com/maps/documentation/javascript/reference/overlay-view#OverlayView
*/
position?: google.maps.LatLngLiteral
position?: google.maps.LatLng | google.maps.LatLngLiteral
/**
* Initial open state for the uncontrolled mode (when `v-model:open` is not
* bound). When omitted, the overlay opens on mount, matching v0 behaviour.
*
* Has no effect when `v-model:open` is used; pass an initial value to the
* bound ref instead.
* @default true
*/
defaultOpen?: boolean
/**
* Anchor point of the overlay relative to its position.
* @default 'bottom-center'
Expand Down Expand Up @@ -80,43 +92,58 @@ export interface ScriptGoogleMapsOverlayViewExpose {
<script setup lang="ts">
import { computed, inject, ref, useTemplateRef, watch } from 'vue'
import { MARKER_CLUSTERER_INJECTION_KEY } from './types'
import { defineDeprecatedAlias, MARKER_INJECTION_KEY, useGoogleMapsResource } from './useGoogleMapsResource'
import { defineDeprecatedAlias, MARKER_INJECTION_KEY, normalizeLatLng, useGoogleMapsResource } from './useGoogleMapsResource'

defineOptions({
inheritAttrs: false,
})

const props = withDefaults(defineProps<ScriptGoogleMapsOverlayViewProps>(), {
anchor: 'bottom-center',
pane: 'floatPane',
blockMapInteraction: true,
panOnOpen: true,
hideWhenClustered: true,
})

defineEmits<ScriptGoogleMapsOverlayViewEmits>()
const {
position,
defaultOpen = true,
anchor = 'bottom-center',
offset,
pane = 'floatPane',
zIndex,
blockMapInteraction = true,
panOnOpen = true,
hideWhenClustered = true,
} = defineProps<ScriptGoogleMapsOverlayViewProps>()

defineSlots<ScriptGoogleMapsOverlayViewSlots>()

// Controlled vs uncontrolled open state.
// - When the parent binds `v-model:open`, `open` becomes a controlled
// model that writes through `emit('update:open', value)`.
// - When the parent omits it, `open` is a local ref managed by the
// component, seeded from `defaultOpen` (which defaults to `true`,
// preserving v0 behaviour where the overlay opens on mount).
//
// `defineModel` is used here (rather than reactive prop destructure) because
// it accepts `default: undefined`, which opts out of Vue's boolean prop
// coercion that would otherwise turn an unset `open` into `false`. We then
// seed the local default below if the model is uncontrolled.
const open = defineModel<boolean>('open', { default: undefined })
if (open.value === undefined)
open.value = defaultOpen ?? true

const markerContext = inject(MARKER_INJECTION_KEY, undefined)
const markerClustererContext = inject(MARKER_CLUSTERER_INJECTION_KEY, undefined)

// Read position fresh each call β€” NOT a computed, because Google Maps object
// internal state (marker.getPosition()) is invisible to Vue's reactivity.
// A computed would cache stale coordinates after marker drag.
// Read position fresh each call: NOT a computed, because Google Maps object
// internal state (marker.getPosition()) is invisible to Vue's reactivity. A
// computed would cache stale coordinates after marker drag.
//
// `position` may be either a `LatLngLiteral` or a `google.maps.LatLng` instance,
// so we normalize through `normalizeLatLng` (which checks for callable `.lat`
// rather than relying on `instanceof`, since mocked APIs in tests return plain
// objects).
function getResolvedPosition(): google.maps.LatLngLiteral | undefined {
if (props.position)
return props.position
if (markerContext?.advancedMarkerElement.value) {
const markerPosition = markerContext.advancedMarkerElement.value.position
if (markerPosition) {
return markerPosition instanceof google.maps.LatLng
? { lat: markerPosition.lat(), lng: markerPosition.lng() }
: { lat: markerPosition.lat, lng: markerPosition.lng }
}
}
if (position)
return normalizeLatLng(position)
const markerPosition = markerContext?.advancedMarkerElement.value?.position
if (markerPosition)
return normalizeLatLng(markerPosition)
return undefined
}

Expand Down Expand Up @@ -180,21 +207,21 @@ function panMapToFitOverlay(el: HTMLElement, map: google.maps.Map, padding: numb
const overlay = useGoogleMapsResource<google.maps.OverlayView>({
// ready condition accesses .value on ShallowRefs β€” tracked by whenever() in useGoogleMapsResource
ready: () => !!overlayContent.value
&& !!(props.position || markerContext?.advancedMarkerElement.value),
&& !!(position || markerContext?.advancedMarkerElement.value),
create({ mapsApi, map }) {
const el = overlayContent.value!

class CustomOverlay extends mapsApi.OverlayView {
override onAdd() {
const panes = this.getPanes()
if (panes) {
panes[props.pane].appendChild(el)
if (props.blockMapInteraction)
panes[pane].appendChild(el)
if (blockMapInteraction)
mapsApi.OverlayView.preventMapHitsAndGesturesFrom(el)
}
if (props.panOnOpen) {
if (panOnOpen) {
// Wait for draw() to position the element, then pan
const padding = typeof props.panOnOpen === 'number' ? props.panOnOpen : 40
const padding = typeof panOnOpen === 'number' ? panOnOpen : 40
requestAnimationFrame(() => {
panMapToFitOverlay(el, map, padding)
})
Expand All @@ -209,8 +236,8 @@ const overlay = useGoogleMapsResource<google.maps.OverlayView>({
return
}

const position = getResolvedPosition()
if (!position) {
const resolvedPosition = getResolvedPosition()
if (!resolvedPosition) {
isPositioned.value = false
hideElement(el)
return
Expand All @@ -222,7 +249,7 @@ const overlay = useGoogleMapsResource<google.maps.OverlayView>({
return
}
const pos = projection.fromLatLngToDivPixel(
new mapsApi.LatLng(position.lat, position.lng),
new mapsApi.LatLng(resolvedPosition.lat, resolvedPosition.lng),
)
if (!pos) {
isPositioned.value = false
Expand All @@ -231,11 +258,11 @@ const overlay = useGoogleMapsResource<google.maps.OverlayView>({
}

el.style.position = 'absolute'
el.style.left = `${pos.x + (props.offset?.x ?? 0)}px`
el.style.top = `${pos.y + (props.offset?.y ?? 0)}px`
el.style.transform = ANCHOR_TRANSFORMS[props.anchor]
if (props.zIndex !== undefined)
el.style.zIndex = String(props.zIndex)
el.style.left = `${pos.x + (offset?.x ?? 0)}px`
el.style.top = `${pos.y + (offset?.y ?? 0)}px`
el.style.transform = ANCHOR_TRANSFORMS[anchor]
if (zIndex !== undefined)
el.style.zIndex = String(zIndex)
el.style.visibility = 'visible'
el.style.pointerEvents = 'auto'
setDataState(el, 'open')
Expand Down Expand Up @@ -279,32 +306,33 @@ if (markerContext) {
watch(
() => {
const markerPosition = markerContext.advancedMarkerElement.value?.position
if (!markerPosition)
return undefined
return markerPosition instanceof google.maps.LatLng
? { lat: markerPosition.lat(), lng: markerPosition.lng() }
: { lat: markerPosition.lat, lng: markerPosition.lng }
return markerPosition ? normalizeLatLng(markerPosition) : undefined
},
() => { overlay.value?.draw() },
)
}

// Reposition on prop changes (draw() is designed to be called repeatedly)
// Only watches explicit props β€” marker position changes are handled by event listeners above
// Reposition on prop changes (draw() is designed to be called repeatedly).
// Only watches explicit props; marker position changes are handled by the
// listeners above. `position` is normalized so that callable-coordinate
// LatLng instances produce a stable identity in the watch source.
watch(
() => [props.position?.lat, props.position?.lng, props.offset?.x, props.offset?.y, props.zIndex, props.anchor],
() => {
const p = position ? normalizeLatLng(position) : undefined
return [p?.lat, p?.lng, offset?.x, offset?.y, zIndex, anchor]
},
() => { overlay.value?.draw() },
)

// v-model:open β€” toggle visibility without remounting the overlay
// Toggle visibility without remounting the overlay when `open` changes.
watch(() => open.value, () => {
if (!overlay.value)
return
overlay.value.draw()
})

// Pane or blockMapInteraction change requires remount (setMap cycles onRemove + onAdd + draw)
watch([() => props.pane, () => props.blockMapInteraction], () => {
watch([() => pane, () => blockMapInteraction], () => {
if (overlay.value) {
const map = overlay.value.getMap()
overlay.value.setMap(null)
Expand All @@ -318,7 +346,7 @@ if (markerClustererContext && markerContext) {
watch(
() => markerClustererContext.clusteringVersion.value,
() => {
if (!props.hideWhenClustered || open.value === false)
if (!hideWhenClustered || open.value === false)
return
const clusterer = markerClustererContext.markerClusterer.value as any
if (!clusterer?.clusters)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ export interface GoogleMapsResourceContext {
mapsApi: typeof google.maps
}

/**
* Normalizes a `LatLng | LatLngLiteral` value into a plain `LatLngLiteral`.
*
* Google's `LatLng` exposes coordinates via `.lat()`/`.lng()` methods, while
* `LatLngLiteral` exposes them as plain `lat`/`lng` numeric properties. The
* runtime distinguishes them by checking whether `.lat` is callable; this is
* preferred over `instanceof google.maps.LatLng` because mocked APIs in tests
* return plain objects rather than real `LatLng` instances.
*/
export function normalizeLatLng(
p: google.maps.LatLng | google.maps.LatLngLiteral,
): google.maps.LatLngLiteral {
if (typeof p.lat === 'function') {
const ll = p as google.maps.LatLng
return { lat: ll.lat(), lng: ll.lng() }
}
return { lat: p.lat as number, lng: p.lng as number }
}

/**
* Defines a deprecated property alias on an exposed object. Reading the alias
* returns the value of the canonical key and emits a one-shot
Expand Down
Loading
Loading