Search code examples
vue.jsleafletvuejs3vuejs3-composition-api

How to pass a component in VueJS 3 to a Leaflet popup which takes string | HTMLElement while keeping it's reactivity?


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)


Solution

  • 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>