Skip to content

Commit c33473c

Browse files
authored
refactor(google-maps)!: OverlayView class extraction and reactive position rendering (#697)
1 parent 7bfc780 commit c33473c

File tree

4 files changed

+269
-117
lines changed

4 files changed

+269
-117
lines changed

docs/content/docs/4.migration-guide/1.v0-to-v1.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,31 @@ The same rename applies to `<ScriptGoogleMapsOverlayView>`{lang="html"}: its exp
414414
})
415415
```
416416

417+
#### `<ScriptGoogleMapsOverlayView>`{lang="html"} DOM Structure
418+
419+
The overlay view now renders an extra wrapper element. The structure is `anchor div → content div → slot`, where the anchor handles positioning (Google Maps reparents it into a pane on mount) and the content div carries `data-state` plus any forwarded `class`/attrs.
420+
421+
CSS that targets `[data-state="open"]` on the slot's root element should move to a class on the `<ScriptGoogleMapsOverlayView>`{lang="html"} itself, since the data-state attribute now lives on the internal content div rather than propagating into the slot's first child.
422+
423+
```diff
424+
<template>
425+
- <ScriptGoogleMapsOverlayView v-model:open="open">
426+
- <div class="overlay-popup">
427+
- ...
428+
- </div>
429+
- </ScriptGoogleMapsOverlayView>
430+
+ <ScriptGoogleMapsOverlayView class="overlay-popup" v-model:open="open">
431+
+ ...
432+
+ </ScriptGoogleMapsOverlayView>
433+
</template>
434+
435+
<style scoped>
436+
-.overlay-popup[data-state="open"] { animation: ... }
437+
+/* Use :deep() in scoped styles since the class lands on a child component's element */
438+
+:deep(.overlay-popup[data-state="open"]) { animation: ... }
439+
</style>
440+
```
441+
417442
### Google Maps Static Placeholder ([#673](https://github.com/nuxt/scripts/pull/673))
418443

419444
v1 extracts the built-in static map placeholder into a standalone [`<ScriptGoogleMapsStaticMap>`{lang="html"}](/scripts/google-maps/api/static-map) component. This removes the following props from `<ScriptGoogleMaps>`{lang="html"}:

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

Lines changed: 108 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export interface ScriptGoogleMapsOverlayViewExpose {
9090
</script>
9191

9292
<script setup lang="ts">
93-
import { computed, inject, ref, useTemplateRef, watch } from 'vue'
93+
import { computed, inject, shallowRef, useTemplateRef, watch } from 'vue'
9494
import { MARKER_CLUSTERER_INJECTION_KEY } from './types'
9595
import { defineDeprecatedAlias, MARKER_INJECTION_KEY, normalizeLatLng, useGoogleMapsResource } from './useGoogleMapsResource'
9696
@@ -159,30 +159,45 @@ const ANCHOR_TRANSFORMS: Record<ScriptGoogleMapsOverlayAnchor, string> = {
159159
'right-center': 'translate(-100%, -50%)',
160160
}
161161
162-
const overlayContent = useTemplateRef('overlay-content')
162+
const overlayAnchor = useTemplateRef('overlay-anchor')
163163
164-
// Reactive open/closed state for CSS animations via data-state attribute.
165-
// Tracks whether the overlay content is positioned and should be visually open.
166-
const isPositioned = ref(false)
167-
const dataState = computed(() => isPositioned.value ? 'open' : 'closed')
164+
// Reactive pixel position written by `draw()`. The template style binding
165+
// on the anchor element reads it, so position updates flow through Vue's
166+
// reactivity instead of imperative `el.style.left/top` writes.
167+
const overlayPosition = shallowRef<{ x: number, y: number } | undefined>(undefined)
168168
169-
// Track all event listeners for clean teardown
170-
const listeners: google.maps.MapsEventListener[] = []
169+
// `dataState` reflects the visible/hidden state of the overlay. It is bound
170+
// directly on the content element so CSS animations targeting `[data-state]`
171+
// react without any imperative DOM writes.
172+
const dataState = computed<'open' | 'closed'>(() =>
173+
open.value !== false && overlayPosition.value !== undefined ? 'open' : 'closed',
174+
)
171175
172-
function setDataState(el: HTMLElement, state: 'open' | 'closed') {
173-
el.dataset.state = state
174-
// Propagate to the slot's root element imperatively (Vue template bindings
175-
// don't reliably patch elements that have been moved to Google Maps panes)
176-
const child = el.firstElementChild as HTMLElement | null
177-
if (child)
178-
child.dataset.state = state
179-
}
176+
// Computed style for the anchor element. Vue patches the moved DOM node via
177+
// this binding even after Google Maps has reparented it into a pane.
178+
const overlayStyle = computed<Record<string, string | undefined>>(() => {
179+
const visible = open.value !== false && overlayPosition.value !== undefined
180+
if (!visible) {
181+
return {
182+
position: 'absolute',
183+
visibility: 'hidden',
184+
pointerEvents: 'none',
185+
}
186+
}
187+
const { x, y } = overlayPosition.value!
188+
return {
189+
position: 'absolute',
190+
left: `${x + (offset?.x ?? 0)}px`,
191+
top: `${y + (offset?.y ?? 0)}px`,
192+
transform: ANCHOR_TRANSFORMS[anchor],
193+
zIndex: zIndex !== undefined ? String(zIndex) : undefined,
194+
visibility: 'visible',
195+
pointerEvents: 'auto',
196+
}
197+
})
180198
181-
function hideElement(el: HTMLElement) {
182-
el.style.visibility = 'hidden'
183-
el.style.pointerEvents = 'none'
184-
setDataState(el, 'closed')
185-
}
199+
// Track all event listeners for clean teardown
200+
const listeners: google.maps.MapsEventListener[] = []
186201
187202
function panMapToFitOverlay(el: HTMLElement, map: google.maps.Map, padding: number) {
188203
const child = el.firstElementChild
@@ -204,87 +219,76 @@ function panMapToFitOverlay(el: HTMLElement, map: google.maps.Map, padding: numb
204219
map.panBy(panX, panY)
205220
}
206221
207-
const overlay = useGoogleMapsResource<google.maps.OverlayView>({
208-
// ready condition accesses .value on ShallowRefs — tracked by whenever() in useGoogleMapsResource
209-
ready: () => !!overlayContent.value
210-
&& !!(position || markerContext?.advancedMarkerElement.value),
211-
create({ mapsApi, map }) {
212-
const el = overlayContent.value!
213-
214-
class CustomOverlay extends mapsApi.OverlayView {
215-
override onAdd() {
216-
const panes = this.getPanes()
217-
if (panes) {
218-
panes[pane].appendChild(el)
219-
if (blockMapInteraction)
220-
mapsApi.OverlayView.preventMapHitsAndGesturesFrom(el)
221-
}
222-
if (panOnOpen) {
223-
// Wait for draw() to position the element, then pan
224-
const padding = typeof panOnOpen === 'number' ? panOnOpen : 40
225-
requestAnimationFrame(() => {
226-
panMapToFitOverlay(el, map, padding)
227-
})
228-
}
222+
// Factory that builds the OverlayView subclass. Lifted out of `create()`
223+
// so the create callback stays focused on wiring (instantiation, listeners).
224+
// The class still has to extend `mapsApi.OverlayView`, which is only
225+
// available after the script loads, so this stays a function rather than
226+
// a top-level class declaration.
227+
function makeOverlayClass(mapsApi: typeof google.maps, map: google.maps.Map) {
228+
return class CustomOverlay extends mapsApi.OverlayView {
229+
override onAdd() {
230+
const panes = this.getPanes()
231+
const el = overlayAnchor.value
232+
if (panes && el) {
233+
panes[pane].appendChild(el)
234+
if (blockMapInteraction)
235+
mapsApi.OverlayView.preventMapHitsAndGesturesFrom(el)
229236
}
230-
231-
override draw() {
232-
// v-model:open support: hide when explicitly closed
233-
if (open.value === false) {
234-
isPositioned.value = false
235-
hideElement(el)
236-
return
237-
}
238-
239-
const resolvedPosition = getResolvedPosition()
240-
if (!resolvedPosition) {
241-
isPositioned.value = false
242-
hideElement(el)
243-
return
244-
}
245-
const projection = this.getProjection()
246-
if (!projection) {
247-
isPositioned.value = false
248-
hideElement(el)
249-
return
250-
}
251-
const pos = projection.fromLatLngToDivPixel(
252-
new mapsApi.LatLng(resolvedPosition.lat, resolvedPosition.lng),
253-
)
254-
if (!pos) {
255-
isPositioned.value = false
256-
hideElement(el)
257-
return
258-
}
259-
260-
el.style.position = 'absolute'
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)
266-
el.style.visibility = 'visible'
267-
el.style.pointerEvents = 'auto'
268-
setDataState(el, 'open')
269-
isPositioned.value = true
237+
if (panOnOpen) {
238+
// Wait for draw() to position the element, then pan
239+
const padding = typeof panOnOpen === 'number' ? panOnOpen : 40
240+
requestAnimationFrame(() => {
241+
if (overlayAnchor.value)
242+
panMapToFitOverlay(overlayAnchor.value, map, padding)
243+
})
270244
}
245+
}
271246
272-
override onRemove() {
273-
el.parentNode?.removeChild(el)
247+
override draw() {
248+
if (open.value === false) {
249+
overlayPosition.value = undefined
250+
return
251+
}
252+
const resolvedPosition = getResolvedPosition()
253+
if (!resolvedPosition) {
254+
overlayPosition.value = undefined
255+
return
256+
}
257+
const projection = this.getProjection()
258+
if (!projection) {
259+
overlayPosition.value = undefined
260+
return
261+
}
262+
const pos = projection.fromLatLngToDivPixel(
263+
new mapsApi.LatLng(resolvedPosition.lat, resolvedPosition.lng),
264+
)
265+
if (!pos) {
266+
overlayPosition.value = undefined
267+
return
274268
}
269+
overlayPosition.value = { x: pos.x, y: pos.y }
275270
}
276271
277-
// Prevent flash: hide until first draw() positions content
278-
el.style.visibility = 'hidden'
279-
el.style.pointerEvents = 'none'
272+
override onRemove() {
273+
const el = overlayAnchor.value
274+
el?.parentNode?.removeChild(el)
275+
}
276+
}
277+
}
280278
279+
const overlay = useGoogleMapsResource<google.maps.OverlayView>({
280+
// ready condition accesses .value on ShallowRefs — tracked by whenever() in useGoogleMapsResource
281+
ready: () => !!overlayAnchor.value
282+
&& !!(position || markerContext?.advancedMarkerElement.value),
283+
create({ mapsApi, map }) {
284+
const CustomOverlay = makeOverlayClass(mapsApi, map)
281285
const ov = new CustomOverlay()
282286
ov.setMap(map)
283287
284-
// Follow parent marker position changes
288+
// Follow parent marker position changes. AdvancedMarkerElement fires
289+
// `drag` continuously during drag, so the overlay tracks live.
285290
if (markerContext?.advancedMarkerElement.value) {
286291
const ame = markerContext.advancedMarkerElement.value
287-
// AdvancedMarkerElement fires drag continuously during drag
288292
listeners.push(
289293
ame.addListener('drag', () => ov.draw()),
290294
ame.addListener('dragend', () => ov.draw()),
@@ -386,8 +390,20 @@ defineExpose<ScriptGoogleMapsOverlayViewExpose>(exposed)
386390

387391
<template>
388392
<div style="display: none;">
389-
<div ref="overlay-content" :data-state="dataState" v-bind="$attrs">
390-
<slot />
393+
<!--
394+
Two-element structure:
395+
- `overlay-anchor` is moved into a Google Maps pane on `onAdd()`. Its
396+
inline style is reactively bound to `overlayStyle`, so position
397+
updates from `draw()` flow through Vue's patcher even after the node
398+
has been reparented out of the component tree.
399+
- `overlay-content` carries `data-state`, attribute-based animations,
400+
and forwards parent attrs (e.g. `class`) so consumers can target it
401+
directly with `[data-state]` selectors.
402+
-->
403+
<div ref="overlay-anchor" :style="overlayStyle">
404+
<div :data-state="dataState" v-bind="$attrs">
405+
<slot />
406+
</div>
391407
</div>
392408
</div>
393409
</template>

playground/pages/third-parties/google-maps/overlay-animated.vue

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -45,53 +45,58 @@ function close(id: number) {
4545
@click="toggle(place.id)"
4646
>
4747
<ScriptGoogleMapsOverlayView
48+
class="overlay-popup"
4849
:open="isOpen(place.id)"
4950
anchor="bottom-center"
5051
:offset="{ x: 0, y: -50 }"
5152
@update:open="(v: boolean) => { if (!v) close(place.id) }"
5253
>
53-
<div class="overlay-popup">
54-
<div class="flex items-start justify-between gap-2">
55-
<h3 class="text-sm font-semibold text-gray-900">
56-
{{ place.name }}
57-
</h3>
58-
<button
59-
class="shrink-0 rounded-full p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
60-
@click.stop="close(place.id)"
61-
>
62-
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 20 20" fill="currentColor">
63-
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
64-
</svg>
65-
</button>
66-
</div>
67-
<div class="mt-1 flex items-center gap-1 text-xs text-gray-500">
68-
<span class="font-medium text-yellow-500">★ {{ place.rating }}</span>
69-
<span>({{ place.reviews }} reviews)</span>
70-
</div>
71-
<p class="mt-2 text-xs leading-relaxed text-gray-600">
72-
{{ place.desc }}
73-
</p>
74-
</div>
54+
<div class="flex items-start justify-between gap-2">
55+
<h3 class="text-sm font-semibold text-gray-900">
56+
{{ place.name }}
57+
</h3>
58+
<button
59+
class="shrink-0 rounded-full p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
60+
@click.stop="close(place.id)"
61+
>
62+
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 20 20" fill="currentColor">
63+
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
64+
</svg>
65+
</button>
66+
</div>
67+
<div class="mt-1 flex items-center gap-1 text-xs text-gray-500">
68+
<span class="font-medium text-yellow-500">★ {{ place.rating }}</span>
69+
<span>({{ place.reviews }} reviews)</span>
70+
</div>
71+
<p class="mt-2 text-xs leading-relaxed text-gray-600">
72+
{{ place.desc }}
73+
</p>
7574
</ScriptGoogleMapsOverlayView>
7675
</ScriptGoogleMapsMarker>
7776
</ScriptGoogleMaps>
7877
</div>
7978
</template>
8079

8180
<style scoped>
82-
.overlay-popup {
81+
/*
82+
* `.overlay-popup` is forwarded onto the OverlayView's internal content div
83+
* (via `v-bind="$attrs"`). Scoped styles need `:deep()` to reach across the
84+
* component boundary, since the rendered element belongs to the
85+
* `<ScriptGoogleMapsOverlayView>` component, not this page.
86+
*/
87+
:deep(.overlay-popup) {
8388
width: 16rem;
8489
border-radius: 0.75rem;
8590
background: white;
8691
padding: 1rem;
8792
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
8893
}
8994
90-
.overlay-popup[data-state="open"] {
95+
:deep(.overlay-popup[data-state="open"]) {
9196
animation: overlayIn 200ms ease-out forwards;
9297
}
9398
94-
.overlay-popup[data-state="closed"] {
99+
:deep(.overlay-popup[data-state="closed"]) {
95100
animation: overlayOut 150ms ease-in forwards;
96101
}
97102

0 commit comments

Comments
 (0)