TIL: Custom Co-existing Contextmenus - Right-click Fun For Everyone

charles-oursails avatar

charles-oursails

Summary: Why & What

Our users wanted an easy way to access some quick actions without looking at a larger menu, and some users did not want to sacrifice the browser's built in context menu that they depended on. We wanted to offer both a custom contextmenu (aka. the right-click menu) when users were editing a document but still give them the option to access the default browser right-click menu if they depended on it (and some did depend on it!).

Custom Contextmenu
Custom Contextmenu

Painful counter-examples

In the world of web applications, you can find a number of sites that fully disable right-click context menus for one reason or another. I won't get into those cases other than to say it's annoying to encounter such sites and I usually don't revisit them.

In most web applications that decide to override the browser's default contextmenu, they do so with the best of intentions. They usually wish to offer a better experience, and most often they do deliver on that wish - by providing context sensitive options that are helpful. Google docs is a great widely known example:

Google Docs Contextmenu is helpful
Google Docs Contextmenu is helpful

But what's missing from that example?

Each person has their own expectations of what should be there, and here are a few expectations I've heard from users:

  • Oof, where did my standard spell correct and grammar correct options go??
  • "Print is missing but that's strange, I always see it there"
  • Developers might call out the lack of "View Source" or "Inspect"
  • My language translation tool options are not accessible
  • "My favorite extension is not listed"
  • Send to my device is not in the right-click menu anymore

Again, the amount of pain experienced and the perception of these features now missing is quite subjective. But hearing any of these remarks, complaints or quizzical statements in a user's moment of confusion and mild frustration is a sign that there is friction. What was expected is not there, the experience has been diminished even when the app developers intention was never so.

Common Solutions

The most common solution to this "who moved my cheese" scenario seems to be a get used to it response from even the most popular apps. Google Docs, for instance, shows no outward concern for users that may have wanted to access their standard context menu. And most other smaller budgeted tech organizations seem to follow suit - get used to it.

But when you look past the surface you'll find that at least Google does care, but they only care for those that already know the secret key combination: ㊙️ Option key (⌥) Shift and right click ㊙️

What normal user knows that? 😑 🙅
None that I know or have talked to!

Another Option

What we did, in our first version, is to offer a setting baked into the context menu that allows users to toggle on the standard right-click context menu so that both can live in harmony if the user really wants to access their custom extensions and standard context menu. With some helpful tours and info markers it'll become more obvious.

It looks like this:

custom context menu with toggle
custom contextmenu with toggle on

And when selecting this option a cookie is set that remembers the user's preference, and the standard "preventDefault" javascript blocker of the browser's default context menu is removed. Now users get to see the browser's context menu and our custom menu side-by-side.

With a little care regarding screen real estate, they can both co-exist peacefully.

And they look something like this:
co-existing context menus

Outcome

  • This option let's our users that regularly want to interact with their standard right-click menu the option to get both.
  • For those that want to only occasionally access it, they can immediately toggle it off when they don't need it.
  • And for the majority of users, they get the thoughtful context menu that we've custom developed specific to the application they are using.

I think it's a win-win, a simple concession with surprisingly minimal drawbacks. If you are in the business of preventing users from using the tools they prefer, then this isn't for you. We'd prefer people have as little friction as possible, good defaults but flexibility when it helps.

The code

https://OurSails.com is largely built on Rails, Alpine, Tailwind. Here's a sample of the frontend code leveraging Alpine and Tailwind that should work as a drop-in for any app using those same technologies:

<div
  class="relative px-0 py-0 bg-gray-100 dark:text-darkmodetext min-height-full w-full"
  x-init="contextMenuBoth = getCookie('contextmenu') == 'true'  ? true : false;"
  x-data="{
      contextMenuBoth: false,
      contextMenuOpen: false,
      contextMenuToggle: function(event) {
          this.contextMenuOpen = true;
          if(!this.contextMenuBoth){ event.preventDefault(); }
          this.$refs.contextmenu.classList.add('opacity-0');
          let that = this;
          $nextTick(function(){
              that.calculateContextMenuPosition(event);
              that.calculateSubMenuPosition(event);
              that.$refs.contextmenu.classList.remove('opacity-0');
          });
      },
      calculateContextMenuPosition (clickEvent) {
          if(window.innerHeight < clickEvent.clientY + this.$refs.contextmenu.offsetHeight){
              this.$refs.contextmenu.style.top = (window.innerHeight - this.$refs.contextmenu.offsetHeight) + 'px';
          } else {
              this.$refs.contextmenu.style.top = clickEvent.clientY + 'px';
          }
          if(this.contextMenuBoth || window.innerWidth < clickEvent.clientX + this.$refs.contextmenu.offsetWidth){
              this.$refs.contextmenu.style.left = (clickEvent.clientX - this.$refs.contextmenu.offsetWidth) + 'px';
          } else {
              this.$refs.contextmenu.style.left = clickEvent.clientX + 'px';
          }
      },
      calculateSubMenuPosition (clickEvent) {
          let submenus = document.querySelectorAll('[data-submenu]');
          let contextMenuWidth = this.$refs.contextmenu.offsetWidth;
          for(let i = 0; i < submenus.length; i++){
              if(this.contextMenuBoth || window.innerWidth < (clickEvent.clientX + contextMenuWidth + submenus[i].offsetWidth)){
                  submenus[i].classList.add('left-0', '-translate-x-full');
                  submenus[i].classList.remove('right-0', 'translate-x-full');
              } else {
                  submenus[i].classList.remove('left-0', '-translate-x-full');
                  submenus[i].classList.add('right-0', 'translate-x-full');
              }
              if(window.innerHeight < (submenus[i].previousElementSibling.getBoundingClientRect().top + submenus[i].offsetHeight)){
                  let heightDifference = (window.innerHeight - submenus[i].previousElementSibling.getBoundingClientRect().top) - submenus[i].offsetHeight;
                  submenus[i].style.top = heightDifference + 'px';
              } else {
                  submenus[i].style.top = '';
              }
          }
      }
  }"
  >
  <div class="border border-gray-200 bg-gray-50 max-w-screen-2xl"
    <div @contextmenu="contextMenuToggle(event)"
        x-init="
            $watch('contextMenuOpen', function(value){
                if(value === true){ document.body.classList.add('overflow-hidden') }
                else { document.body.classList.remove('overflow-hidden') }
            });
            window.addEventListener('resize', function(event) { contextMenuOpen = false; });
        ">

      <template x-teleport="body">
        <div x-show="contextMenuOpen" @click.away="contextMenuOpen=false" x-ref="contextmenu"
          class="z-50 min-w-[8rem] text-neutral-800 rounded-md border border-neutral-200/70 bg-white text-sm fixed p-1 shadow-md w-64" x-cloak>

          <div @click="contextMenuOpen=false;" class="relative flex cursor-default select-none group items-center rounded px-2 py-1.5 hover:bg-neutral-100 outline-none pl-2 data-[disabled]:opacity-50 data-[disabled]:pointer-events-none">
              <span>
                Menu Item 1
              </span>
              <span class="ml-auto text-xs tracking-widest text-neutral-400 group-hover:text-neutral-600">
              </span>
          </div>
          <div @click="contextMenuOpen=false;" class="relative flex cursor-default select-none group items-center rounded px-2 py-1.5 hover:bg-neutral-100 outline-none pl-2 data-[disabled]:opacity-50 data-[disabled]:pointer-events-none">
              <span>
                Menu Item 2
              </span>
              <span class="ml-auto text-xs tracking-widest text-neutral-400 group-hover:text-neutral-600">
              </span>
          </div>
          <div class="h-px my-1 -mx-1 bg-neutral-200"></div>
          <div
            @click="contextMenuBoth=!contextMenuBoth; document.cookie = 'contextmenu='+contextMenuBoth+'; expires=Thu, 29 Dec 2033 12:00:00 UTC; path=/'; setTimeout(() => { contextMenuOpen=false;}, 100);"
            class="relative flex cursor-default select-none group items-center rounded px-2 py-1.5 hover:bg-neutral-100 outline-none pl-2 data-[disabled]:opacity-50 data-[disabled]:pointer-events-none">
              <span x-show="!contextMenuBoth">
                (Off)
                Allow browser's context menu
              </span>
              <span x-show="contextMenuBoth">
                (ON)
                Allow browser's context menu
              </span>
              <span class="ml-auto text-xs tracking-widest text-neutral-400 group-hover:text-neutral-600">
              </span>
          </div>
        </div>
      </template>
    </div>
  </div>
</div>

And lastly, we have one helper function that this calls out to and is referenced above, including it here separately, but you can probably work it into the above example and make it all one wholly contained element if you wish:

function getCookie(name) {
  function escape(s) { return s.replace(/([.*+?\^$(){}|\[\]\/\\])/g, '\\$1'); }
  var match = document.cookie.match(RegExp('(?:^|;\\s*)' + escape(name) + '=([^;]*)'));
  return match ? match[1] : null;
};
charles-oursails avatar
Written By

charles-oursails

Engineer, entrepreneur and grateful user of Bloggie. I work on https://oursails.com full time.
Enjoyed the post?

Clap to support the author, help others find it, and make your opinion count.