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)

1

There are 1 best solutions below

0
On BEST ANSWER

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:

  1. Have my component have exactly 1 root element
  2. Add my component to the template but hidden (v-show="false")
  3. Assign a ref to it (called for example popupElement)
  4. Assign an id to it (in case of list rendering)
  5. Expose it's root element with defineExpose as $el
  6. Expose it's id attribute defineExpose as id
  7. Bind it to the marker (in onMounted or watch for the marker if it gets added in subsequently) with leafletMarker.bindPopup(popupElement.$el)
  8. Add the following CSS:
    .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>