Sveltify Any JavaScript Library

Published Sep 29, 2023

Table of Contents

Sveltify Any JavaScript Library

Svelte makes it easy to work with any existing JavaScript library since it gives you control over the DOM and doesn’t require mental gymnastics.

Instead of using regular JavaScript you can take advantage of the declarative nature of Svelte and Sveltify any JavaScript library for a nicer developer experience — which you can also publish on npm if you want.

You can find the source code on GitHub.

First we’re going to use Svelte actions to create a reusable tooltip from Floating UI, and then use component composition to turn a Leaflet map into a simple library we can use.

You don’t need special Svelte version of a library to use it, but it can teach you a lot about how JavaScript frameworks work in general and it’s fun.

Using Svelte Actions

Floating UI is used to create tooltips, popovers, dropdown and more but for our use case I’m only interested in using it for tooltips.

You might look at the code of some sveltified libraries and think it looks complicated, but that’s only because they’re trying to make a general abstraction for everyone to use — you don’t have to, so only Sveltify the parts you need.

Here is the regular code required for creating a Floating UI tooltip using the bind directive to get a reference to the element.

src/routes/tooltip/tooltip.svelte
<script lang="ts">
  import { computePosition, offset, type ComputePositionConfig } from '@floating-ui/dom'

  // get reference to dom elements
  let buttonEl: HTMLButtonElement
  let tooltipEl: HTMLDivElement

  // state
  let showTooltip = false

  async function updateTooltipPosition() {
    // use the Floating UI API
    const { x, y } = await computePosition(buttonEl, tooltipEl, {
      placement: 'bottom',
      middleware: [offset(8)],
    })

    Object.assign(tooltipEl.style, {
      left: `${x}px`,
      top: `${y}px`,
    })
  }

  // run when `showTooltip` changes
  $: if (showTooltip) {
    updateTooltipPosition()
  }
</script>

<button
  bind:this={buttonEl}
  on:mouseenter={() => showTooltip = true}
  on:mouseleave={() => showTooltip = false}
  aria-describedby="tooltip"
>
  Hover
</button>

<div bind:this={tooltipEl} class:show={showTooltip} class="tooltip" role="tooltip">
  Component
</div>

<style>
  .tooltip {
    display: none;
    width: max-content;
    position: absolute;
    top: 0;
    left: 0;
    font-weight: 600;
    background: var(--tooltip-bg);
    color: var(--tooltip-clr);
    padding: var(--tooltip-padding);
    border-radius: var(--tooltip-rounded);

    &.show {
      display: block;
    }
  }
</style>

You can import is any other component, and perhaps even pass arguments to change the values to make it more flexible.

src/routes/tooltip/+page.svelte
<script lang="ts">
  import Tooltip from './tooltip.svelte'
</script>

<!-- Floating UI  -->
<Tooltip />

Instead of creating a <Tooltip /> component, it’s easier to create a Svelte action you can simply use on any element.

Using a Svelte action is a simple way to get a reference to any element. You can attach behavior to any element using regular JavaScript, and run code when the element is created or removed.

src/lib/tooltip.ts
import { computePosition, offset, type Placement } from '@floating-ui/dom'

type TooltipOptions = {
  text?: string
  placement?: Placement
}

export function tooltip(targetEl: HTMLElement, options?: TooltipOptions) {
  const tooltipEl = createTooltip(targetEl)

  function createTooltip(targetEl: HTMLElement) {
    const tooltipEl = Object.assign(document.createElement('div'), {
      role: 'tooltip',
      innerHTML: options?.text ?? 'Tooltip',
      style: `
        display: none;
        width: max-content;
        position: absolute;
        top: 0;
        left: 0;
        font-weight: 600;
        background: var(--tooltip-bg);
        color: var(--tooltip-clr);
        padding: var(--tooltip-padding);
        border-radius: var(--tooltip-rounded);
      `,
    })

    targetEl.after(tooltipEl)

    return tooltipEl
  }

  async function updateTooltipPosition(targetEl: HTMLElement, tooltipEl: HTMLElement) {
    const { x, y } = await computePosition(targetEl, tooltipEl, {
      placement: options?.placement,
      middleware: [offset(8)],
    })

    Object.assign(tooltipEl.style, {
      left: `${x}px`,
      top: `${y}px`,
    })
  }

  function showTooltip() {
    tooltipEl.style.display = 'block'
    updateTooltipPosition(targetEl, tooltipEl)
  }

  function hideTooltip() {
    tooltipEl.style.display = 'none'
  }

  // add event listeners
  targetEl.addEventListener('mouseenter', showTooltip)
  targetEl.addEventListener('mouseleave', hideTooltip)

  return {
    destroy() {
      // remove event listeners when element is removed
      targetEl.removeEventListener('mouseenter', showTooltip)
      targetEl.removeEventListener('mouseLeave', hideTooltip)
    },
  }
}

Looking at the example you can see we’re using regular JavaScript which is incredibly powerful.

src/routes/tooltip/+page.svelte
<script lang="ts">
  import { tooltip } from '$lib/tooltip'
</script>

<!-- Action -->
<button use:tooltip={{ text: 'Bottom' }}>Hover</button>
<button use:tooltip={{ text: 'Right', placement: 'right' }}>Hover</button>

Using a Svelte action we abstracted the tooltip logic into a tooltip action which is just a regular JavaScript function with a reference to the element — you can import the action from anywhere, and use it with the use:action directive.

Using Component Composition

Leaflet is a great JavaScript library for interactive maps, and has some of the most common problems you’re going to run into when using a JavaScript library with SvelteKit.

For some reason Leaflet doesn’t include installation instructions for npm, but doing a quick search on npm you can find Leaflet, and use npm i leaflet to install the package.

If you use TypeScript, besides the name of the package you can see Leaflet has type definitions available from npm i @types/leaflet since it’s not written in TypeScript.

Here is how you use Leaflet in Svelte.

src/routes/map/map.svelte
<script lang="ts">
  import { onMount } from 'svelte'
  import 'leaflet/dist/leaflet.css'

  let mapEl: HTMLDivElement

  onMount(async () => {
    // using dynamic import because leaflet runs
    // code on the `window` object during init
    const leaflet = await import('leaflet')

    // create map of Croatia
    const map = leaflet.map(mapEl).setView([45.815399, 15.966568], 6)

    // add map tile
    leaflet.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map)

    // create Zagreb marker
    const markerZagreb = leaflet.marker([45.815399, 15.966568]).addTo(map)
    markerZagreb.bindPopup('Zagreb')

    // create Zadar marker
    const markerZadar = leaflet.marker([44.119371, 15.231365]).addTo(map)
    markerZadar.bindPopup('Zadar')
  })
</script>

<div bind:this={mapEl} class="map" />

<style>
  .map {
    position: absolute;
    inset: 0;
    z-index: 10;
  }
</style>

This gives us a nice map.

src/routes/map/+page.svelte
<script lang="ts">
  import LeafletMap from './map.svelte'
</script>

<!-- Leaflet -->
<LeafletMap />

This is great but what if we wanted to create a map in a more declarative way?

src/routes/map/+page.svelte
<script lang="ts">
  import { Map, Marker } from '$lib/map'
</script>

<!-- Composition -->
<Map lat={45.815399} lon={15.966568} zoom={6}>
  <Marker lat={45.815399} lon={15.966568} label="Zagreb" />
  <Marker lat={44.119371} lon={15.231365} label="Zadar" />
</Map>

This already looks a lot nicer! To achieve this we can use component composition with Svelte’s Context API.

Inside lib I’m going to create a map folder with a <Map /> and <Marker /> component.

src/lib/map/map.svelte
<script lang="ts">
  import { onMount, setContext } from 'svelte'
  import type L from 'leaflet'
  import { key } from '$lib/map'
  import 'leaflet/dist/leaflet.css'

  export let lat: number
  export let lon: number
  export let zoom: number

  let leaflet: typeof L
  let leafletMap: L.Map
  let mapEl: HTMLDivElement

  setContext(key, {
    getLeaflet: () => leaflet,
    getMap: () => leafletMap,
  })

  onMount(async () => {
    leaflet = await import('leaflet')
    leafletMap = leaflet.map(mapEl).setView([lat, lon], zoom)
    leaflet.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(leafletMap)
  })
</script>

<div bind:this={mapEl} class="map" />

{#if leaflet && leafletMap}
  <slot />
{/if}

<style>
  .map {
    position: absolute;
    inset: 0;
    z-index: 10;
  }
</style>

The reason we use the Context API is because we need to pass the leaflet and map instance to the child <Marker /> component.

The reason we use a getLeaflet() and getMap() function is because we always want the latest leaflet and map value, otherwise it would be undefined because that’s the initial value.

src/lib/map/marker.svelte
<script lang="ts">
  import { createEventDispatcher, getContext } from 'svelte'
  import { key, type MapContext } from '$lib/map'

  export let lat: number
  export let lon: number
  export let label: string

  // get methods from context
  const { getLeaflet, getMap } = getContext<MapContext>(key)

  // get Leaflet instance and map from context
  const leaflet = getLeaflet()
  const map = getMap()

  // add marker
  const marker = leaflet.marker([lat, lon]).addTo(map)
  marker.bindPopup(label)
</script>

I’m going to create a index.ts file to export everything. This is going to let us import anything from the library using a single import.

src/lib/map/index.ts
import type L from 'leaflet'
import Map from './map.svelte'
import Marker from './marker.svelte'
import { key } from './key'

export type MapContext = {
  getLeaflet: () => typeof L
  getMap: () => L.Map
}

export { Map, Marker, key }

The key in setContext() can be anything like the string map, but to ensure the context is unique for every instance of <Map /> you want to use something unique like an object {} or Symbol.

src/lib/map/key.ts
export const key = Symbol()

Dispatching Custom Events

You can dispatch custom events by using the createEventDispatcher from Svelte.

src/lib/map/marker.svelte
<script lang="ts">
  import { createEventDispatcher } from 'svelte'

  // dispatch custom events
  const dispatch = createEventDispatcher()
  marker.on('popupopen', () => dispatch('open'))
  marker.on('popupclose', () => dispatch('close'))
</script>

You can listen for the custom event using the on:eventname directive.

src/routes/map/+page.svelte
<script lang="ts">
  import { Map, Marker } from '$lib/map'
</script>

<Map lat={45.815399} lon={15.966568} zoom={6}>
  <Marker
    on:open={() => console.log('open')}
    on:close={() => console.log('close')}
    lat={45.815399}
    lon={15.966568}
    label="Zagreb"
  />
  <Marker lat={44.119371} lon={15.231365} label="Zadar" />
</Map>

You can also dispatch custom events from Svelte actions using regular JavaScript, and pass any data alongside the event.

example.svelte
<script lang="ts">
  function action(element: HTMLElement) {
    element.addEventListener('click', () => {
      element.dispatchEvent(new CustomEvent('banana'))
    })
  }
</script>

<button use:action on:banana={() => console.log('🍌')}>
  Click
</button>

That’s it! 😄

Support

You can subscribe on YouTube, or consider becoming a patron if you want to support my work.

Patreon
Found a mistake?

Every post is a Markdown file so contributing is simple as following the link below and pressing the pencil icon inside GitHub to edit it.

Edit on GitHub