DEV Community

Rails Designer
Rails Designer

Posted on Originally published at railsdesigner.com on

A reusable dropdown with custom elements and Popover API

A dropdown looks like a problem solved by many already. But it is not too difficult to build your own and allows to make them exactly how you want them to be. It has to open next to its trigger, flip when there’s no room, stay inside the viewport, move focus into the panel, cycle through its items with the arrow keys, close on Escape and disappear when you click somewhere else.

Then there’s the part I find most useful: the same panel should also work as a right-click context menu, opening where the pointer is instead on a button.

That’s a handful of behaviors together. I keep them in one custom element, <ui-dropdown>.

dropdown-element.jpg

The click version is a trigger plus a panel marked with data-dropdown-panel:

<ui-dropdown>
  <button data-dropdown-trigger>Account</button>

  <div data-dropdown-panel hidden>
    <%= link_to "Profile", profile_path %>
    <%= link_to "Settings", settings_path %>

    <%= button_to "Log out", session_path, method: :delete %>
  </div>
</ui-dropdown>

Enter fullscreen mode Exit fullscreen mode

The context version drops the separate trigger. The element itself is the thing you right-click, so the panel just lives inside it:

<ui-dropdown trigger="context">
  <%= tag.div dom_id(collection), aria: {current: (item.active? ? "page" : nil)} do %>
    <%= item.name %>
  <% end %>

  <div data-dropdown-panel hidden>
    <%= link_to "New resource", resources_path(collection: collection), data: {turbo_method: :post} %>
    <%= link_to "Settings", edit_collection_path(collection) %>
  </div>
</ui-dropdown>

Enter fullscreen mode Exit fullscreen mode

The panel’s contents are entirely yours. Exactly how I like it so I can style the contents how I want and not be constrained. The element only cares that it finds data-dropdown-panel, so the same tag holds a small account menu or a changelog row’s actions. The switch between the two examples is the trigger attribute, and that’s where most of the work sits, so it’s worth walking through.

There are two trigger modes and they differ not just in how they open: in connect, the element reads the trigger attribute. In click mode it finds [data-dropdown-trigger], wires up aria-haspopup, aria-controls and aria-expanded, then listens for a click. In context mode there’s no trigger element at all. It listens for contextmenu on itself:

#contextMenu = (event) => {
  if (this.#panel?.contains(event.target)) return

  event.preventDefault()
  if (this.#open) this.close()

  this.open({ x: event.clientX, y: event.clientY })
}

Enter fullscreen mode Exit fullscreen mode

A second right-click on the same row closes and reopens the panel at the new pointer position. Right-clicking inside the panel is ignored, so the menu doesn’t flicker away while you aim at an item.

The Popover APIthen treats the two modes differently:

panel.setAttribute("popover", this.#triggerMode === "context" ? "manual" : "auto")

Enter fullscreen mode Exit fullscreen mode

[AD] Spinal CMS: Git-based CMS for static site generators

auto is the default for a click menu. It gives light-dismiss (clicking outside closes it) and makes sure only one auto popover is open at a time, all for free. A context menu can’t use that. It needs manual so it can be placed at the cursor and stay open while the right mouse button is processed. The location is the other difference. In click mode the panel anchors to the trigger’s getBoundingClientRect. In context mode it anchors to a zero-size rectangle at the pointer, built by #rectAt. Everything after that, the flipping and the clamping, is shared.

Both modes share the same menu set up (semantics). On connect, the panel gets role="menu" and the trigger gets its aria attributes. Opening flips aria-expanded, sets a data-open hook and moves focus to the first actionable element. The actionables are any non-disabled a or button inside the panel. The arrow key handling wraps around both ends:

const items = actionableElements(this.#panel)
const currentIndex = items.indexOf(document.activeElement)
const delta = event.key === "ArrowDown" ? 1 : -1

const targetIndex =
  currentIndex < 0
    ? (delta === 1 ? 0 : items.length - 1)
    : (currentIndex + delta + items.length) % items.length

items[targetIndex].focus()

Enter fullscreen mode Exit fullscreen mode

ArrowUp from the first item lands on the last, and the other way around. Escape closes the panel and returns focus to the trigger, which is easy to forget and annoying without it. When the panel holds no actionables, the arrows do nothing (rather than focusing the wrong node).

Positioning is only possible on two axes. A placement attribute sets the preferred corner (bottom-start, bottom-end, top-start or top-end, defaulting to bottom-start) and the element flips it when there’s no room:

const vertical =
  spaceBottom < panelRect.height + VIEWPORT_MARGIN && spaceTop > spaceBottom
    ? "top"
    : preferred.vertical
const horizontal =
  spaceRight < panelRect.width + VIEWPORT_MARGIN && spaceLeft > spaceRight
    ? "start"
    : preferred.horizontal

Enter fullscreen mode Exit fullscreen mode

The result is then clamped so even a panel taller than its available room stays inside the viewport with a small margin. Stored scroll and resize listeners call the positioning method again, so the panel tracks its anchor while the page moves.

One small detail makes the wrapper safe to drop anywhere (similar to the recent tooltip element article): the element sets display: contents on itself. That takes the custom element out of the box tree while keeping its children in the normal flow, so your flexbox or grid never “notices” the extra tag.

When the Popover API is missing, the element falls back to position: fixed and toggling hidden. Less elegant, but same behavior. The positioning block itself is also temporary. CSS anchor positioning would replace most of it, the scroll listener and the resize listener, if support were wide enough. It isn’t yet, but once it is that’s the block to delete when coverage catches up.

Why not Stimulus?

As a Rails developer the obvious choice is a Stimulus controller. I didn’t reach for one here (and am decreasingly doing on for contained UI elements). A dropdown and the items inside it are tightly coupled. The element decides which child receives focus, which one the arrow keys move to and which one is clickable. Splitting that across a controller and the markup means pointing at the same nodes twice, once with data attributes and once with targets, and the indirection doesn’t buy much for a behavior this self-contained.

There’s also the question of where the panel lives. Either you copy the same chrome into every view that needs a menu, or you build it inside the controller as a template literal, which puts HTML in JavaScript. I have done this and do not like either option.


As always, the code can be found on GitHub.

Top comments (0)