justin․searls․co

How to add a full screen button to MapKit JS

This week, I've been working on a new multimedia mode for the blog that I call Spots, which are essentially map pins of places I visit.

Rather than merely list all of my Spot posts one-at-a-time as reverse-chronological posts, I also wanted to make a single interactive map that aggregated all my annotations in one place so that readers could explore them spatially. Because I'm a tool for all things Apple, I decided to use the MapKit JS framework they introduced in 2018 for the job.

All told, it was actually pretty easy to get up-and-running and the documentation (while sparse) told me what I needed to do to get a map rendered, centered, and loaded with annotations.

One thing the docs didn't tell me however, was how to let users make a map full screen, and the reason the docs don't mention it is because framework doesn't support it. And that's quite a bummer when your layout's maximum width is as narrow as it is on this humble blog.

So this morning, I figured out a way to hack together a full-screen button that aped the Maps UI's look-and-feel for MapKit JS. Since it's an odd omission, I figured it might be useful to somebody out there if I did a quick show-and-tell on how I did it.

Here's what the button does, before and after clicking it:

Interested? Well, here's what the recipe calls for:

  1. An absolute-positioned div inside the parent element of the map, styled to look like a button and with a couple full-screen zoom icons from Tailwind's excellent Heroicons project
  2. A click handler that that toggles a bunch of Tailwind classes to switch the map's parent div between its regular and fixed full-screen display (also swapping the button icon)
  3. A keyup handler that listens for escape key and—if any maps are full-screened—will exit full screen. This felt necessary while I was testing, because the shrink button can be an awful far distance for one's mouse to travel

The button's HTML

I'm going to just blat the markup here with all of the parameterization removed, so as to save you from reading my ugly Go templates (this site is statically generated with Hugo).

If you're not used to reading Tailwind styles, the class attributes here will be a little overwhelming. Try to take deep breaths:

<div class="p-1 mt-1 bg-secondary beneath-the-page">
  <div data-target="map-container" class="relative w-full max-w-full border-0 h-36 sm:h-56">
    <a data-target="full-screen-button" class="absolute z-10 flex items-center justify-center w-6 h-3 text-gray-700 cursor-pointer hover:text-black top-1 left-1 bg-white/80 rounded-2xl">
      <div data-target="full-screen-icon">
        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-2.5 h-2.5">
          <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
        </svg>
      </div>
      <div data-target="full-screen-inverse-icon" class="hidden">
        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-2.5 h-2.5">
          <path stroke-linecap="round" stroke-linejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
        </svg>
      </div>
    </a>
  </div>
</div>

The JavaScript

I defined two functions to do the work here, one to handle clicks and one that can toggle the full screen state from either a click or the keyboard handle:

const handleFullScreenButton = (el) => {
  el.querySelector('[data-target=full-screen-button').addEventListener('click', e => {
    e.preventDefault()
    const mapWrap = e.target.closest('[data-target="map-container"]')
    const isFullScreen = mapWrap.classList.contains('fixed')
    toggleFullScreen(mapWrap, isFullScreen)
  })
}

const toggleFullScreen = (el, isFullScreen) => {
  const fullScreenClasses = [
    'fixed',
    'h-full',
    'top-0',
    'left-0',
    'z-20'
  ]
  fullScreenClasses.forEach(className => {
    el.classList.toggle(className, !isFullScreen)
  })
  const normalClasses = [
    'relative',
    'h-36',
    'sm:h-56'
  ]
  normalClasses.forEach(className => {
    el.classList.toggle(className, isFullScreen)
  })
  el.querySelector('[data-target="full-screen-icon"]').classList.toggle('hidden', !isFullScreen)
  el.querySelector('[data-target="full-screen-inverse-icon"]').classList.toggle('hidden', isFullScreen)
}

With those defined, the JavaScript just needs to bind click and keyup events to each map wrapper and to the window,

window.addEventListener('DOMContentLoaded', async (event) => {
  // Bind full screen button to all spot maps
  document.querySelectorAll('[data-control="spot-map"]').forEach(handleFullScreenButton)

  // bind escape key ot dismiss full screen if a map is in full screen mode
  window.addEventListener('keyup', event => {
    if (event.key === 'Escape') {
      const fullScreenMap = document.querySelector('[data-target="map-container"].fixed')
      if (fullScreenMap) {
        toggleFullScreen(fullScreenMap, true)
      }
    }
  })
})

Is that it?

That's it! Really makes you wonder why Apple didn't just ship this as part of MapKit JS. It'd really improve the user experience. I mean, just go play with my global Spots map yourself!


Got a taste for fresh, hot takes?

Then you're in luck, because you can subscribe to this site via RSS or Mastodon! And if that ain't enough, then sign up for my newsletter and I'll send you a usually-pretty-good essay once a month. I also have a solo podcast, because of course I do.