I have a Leaflet map. I don't use any VueJS specific wrapper libs, just plain leaflet
. I also use <script setup>
for my VueJS components.
I want to pass a Vue component to a Leaflet popup (to the bindPopup
function) as a content for that popup. It accepts ((layer: L.Layer) => L.Content) | L.Content | L.Popup
. Can I somehow render/convert a component to something that Leaflet can accept as a popup and keep it's reactivity?
_(I was able to somewhat do it by
1. adding my component to the template but hidden (v-show="false"
)
1. assigning a ref
to it (called popupElement
)
1. exposing it's root element with defineExpose
as $el
1. binding it to the marker with leafletMarker.bindPopup(popupElement.$el.innerHTML)
It renders but the reactivity is gone because innerHTML
is just static text with a content at the time of calling it, I assume)
(I found similar questions for Vue2 and for Vue3 but non of them help)
So I got it working with the help of Estus Flask. The solution a mix of both of the solutions posted in the question. I had to:
v-show="false"
)ref
to it (called for example popupElement
)id
to it (in case of list rendering)defineExpose
as $el
id
attribute defineExpose
as id
onMounted
or watch for the marker if it gets added in subsequently) with leafletMarker.bindPopup(popupElement.$el)
.leaflet-popup-content >* {
display: block !important;
}
Here is the full code (The relevant parts for the question):
MapComponent:
<template>
<div v-bind="$attrs" ref="mapElement" :id="$style.map"></div>
<component :is="popupInfo.popup.component" v-show="false" v-for="popupInfo in componentPopups"
:key="popupInfo.id" ref="popupElements2" v-bind="popupInfo.popup.props" :id="popupInfo.id" />
</template>
<script lang="ts">
type ComponentPopup = {
component: Component,
props: Record<string, any>
}
export type Marker = {
id: string | number
location: L.LatLngExpression
icon: string
iconClass: string
popup?: string | ComponentPopup
popupAnchor?: [number, number]
onClick?: () => void
}
export type AppMapProps = {
width: number
height: number
center?: L.LatLngExpression
zoom?: number
markers?: Array<Marker>
}
type MapMarker = {
id: string
marker: L.Marker
}
interface PopupInfo {
id: string
anchor: Marker['popupAnchor']
}
interface StringPopupInfo extends PopupInfo {
popup: string
}
interface ComponentPopupInfo extends PopupInfo {
popup: ComponentPopup
}
const isStringPopupInfo = (popup: StringPopupInfo | ComponentPopupInfo): popup is StringPopupInfo => typeof popup.popup === "string"
const isComponentPopupInfo = (popup: StringPopupInfo | ComponentPopupInfo): popup is ComponentPopupInfo => typeof popup.popup !== "string"
</script>
<script setup lang="ts">
...
const popupElements = ref<Array<any>>([])
...
const mapMarkers = computed<Array<MapMarker>>(() => props.markers?.map((marker: Marker) => {
//map to Array<MapMarker> with event listeners on the L.Marker
const leafletMarker: L.Marker<any> = ...
...
leafletMarker.on({
mouseover: () => {
leafletMarker.openPopup()
},
mouseout: () => {
leafletMarker.closePopup()
},
})
...
return {
id: marker.id,
marker: leafletMarker,
} as MapMarker
}))
watchEffect(() => {
popupElements.value?.forEach(popupElement => {
const leafletMarker = mapMarkers.value.find(marker => marker.id === popupElement.id)?.marker
if (leafletMarker == null || popupElement == null)
return
leafletMarker.bindPopup(popupElement.$el)
})
})
const popups = computed<Array<StringPopupInfo | ComponentPopupInfo>>(() => props.markers.
filter((marker: Marker): marker is Marker & { popup: NonNullable<Marker['popup']> } => marker.popup != null).
map((marker) => ({
id: marker.id,
popup: marker.popup,
anchor: marker.popupAnchor,
} as StringPopupInfo | ComponentPopupInfo))
)
const componentPopups = computed(() => popups.value.filter(isComponentPopupInfo))
...
</script>
<style lang="scss" module>
:global {
.leaflet-popup-content >* {
display: block !important;
}
}
</style>
PopupComponent:
<template>
<section ref="$el">
<div>{{ name }}</div>
<div>{{ counter }}</div>
</section>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { useAttrs } from "vue"
const props = defineProps<{
name: string
}>()
const attrs = useAttrs()
const $el = ref<HTMLElement>()
const counter = ref(0)
window.setInterval(() => {
counter.value += 1
}, 1000)
defineExpose({
$el,
id: attrs.id,
})
</script>
<style lang="scss" module>
</style>
ViewComponent
<template>
<div>
<MapComponent :markers="markers" />
</div>
</template>
<script lang="ts" setup>
import PopupComponent from "@/components/PopupComponent.vue"
import markerSvg from "@/assets/images/icons/marker.svg?raw"
...
const css = useCssModule()
...
const markers = computed() => someData.map(data => ({
id: data.id,
location: [ data.location.lat, data.location.lng ],
icon: markerSvg,
iconClass: css['marker-icon'],
popup: {
component: PopupComponent,
props: {
name: data.name,
},
},
popupAnchor: [ 0, -40 ]
})))
</script>
<style lang="scss" module>
.marker-icon {
...
}
</style>