Dropdowns

Summary

Click-toggled menus anchored to a trigger. Items can be plain links, grouped sections, or stateful checkbox/radio items (checkboxes keep the menu open). The menu is a native popover="auto" shown in the browser's top layer: it paints above everything, including open modal dialogs, and the platform provides light dismiss (outside click) and Esc. Placement is flip-and-spill-aware (RTL included). Menu/menuitem ARIA roles are applied on every show, so even ajax-replaced content stays announced correctly. The notifications panel is the same machinery wearing different content.

When to use

PatternUse it for
Plain menuCommand lists behind a button — row actions, Export.
SectionsLonger menus with natural groupings; add uppercase headings.
Checkbox / radio itemsView options: visible columns (checkbox, stays open), density (radio, exclusive).
Icon-only triggerRow-level "more" menus where a chevron would be noise; add -arrowless.
Notifications panelThe navbar bell: header, unread rows with semantic icons, footer. See the example below.

Examples

Plain menu

Links in a list; the trigger grows its chevron automatically.

Sections + headings

Uppercase headings with dividers between groups.

Checkbox items

Toggling a checkbox keeps the menu open for the next one.

Radio items

Exclusive per <ul> — checking one unchecks its siblings.

Disabled item

.soma-disabled + aria-disabled: the cursor skips it, Enter does nothing.

Icon-only trigger

-arrowless:

Suppresses the automatic chevron; keep an aria-label.

Programmatic + events

.show() and .on('show'|'hide'):

Last event: (none)

Notifications panel

A dropdown2 wearing panel content:

Header, unread rows with semantic icons, footer — same trigger machinery as every menu.

Keyboard navigation

KeyAction
on the triggerOpen (if needed) and move focus to the first item — the WAI-ARIA menu-button pattern.
/ Move the cursor down / up, wrapping at both ends.
Tab / Shift+TabSame as the arrows: the cursor moves with wrap, so Tab cannot escape into the page behind an open menu.
Home / EndJump to the first / last item.
EnterInvoke the cursor item (same as clicking it).
EscClose and return focus to the trigger.

The cursor model: on show, the first item is pre-selected (.soma-active) so Enter works immediately — focus stays on the trigger until an arrow or Tab is pressed. Keyboard movement carries real DOM focus so screen readers announce each item; mouse movement re-anchors the same highlight visually without stealing focus. Disabled items are skipped entirely. Escape always returns focus to the trigger, in every engine, including ones that never focus buttons on click (Safari). Any other hide returns it when focus was inside the menu — an outside click keeps focus where the user clicked.

Menu semantics (applied per show)

On every show() the component (re-)applies the ARIA menu pattern: the menu gets role="menu", the ul/li wrappers role="none", and each item role="menuitem" / menuitemcheckbox / menuitemradio according to its class, so assistive tech announces "menu, 5 items" and checked state. Because this runs per show (it's idempotent and cheap), menus whose content is replaced after init (ajax results, re-rendered fragments) stay correctly announced without re-binding.

Item behaviour on click: .soma-disabled items are ignored; a checkbox item flips .soma-checked + aria-checked and the menu stays open; a radio item unchecks its siblings in the same <ul>, checks itself, and clicking the already-checked radio is a no-op. Plain links navigate normally — no preventDefault for them.

Positioning & layers

The open menu is a popover="auto" in the native top layer: it renders above every z-index on the page — and above an open modal <dialog>, where it stays fully interactive (a dropdown inside a dialog just works). The top layer also escapes any ancestor's overflow: hidden, transform or stacking context, so the old portal-to-body dance is gone; the menu never leaves its DOM position. Light dismiss (outside click) and Esc come from the platform; auto popovers are also exclusive, so opening one closes any unrelated open menu. Scrolling the page still closes the menu — a Soma behaviour, armed via requestAnimationFrame so the browser's own scroll-into-view nudge on open can't instantly close it.

Placement is below the trigger, start-aligned; it flips above when there's no room below (and more above), end-aligns when it would spill past the viewport edge, and mirrors all of this under RTL. Where the engine supports CSS anchor positioning the placement (including the flips) is pure CSS (position-area with logical keywords plus position-try-fallbacks) and the JS positioner stands down; elsewhere the JS math applies unchanged. Server-rendered inline styles (display/top/left) baked onto the menu are wiped at bind time: inline styles would otherwise beat the aria-hidden CSS. Without the Popover API (a legacy embedder) everything falls back to the layer-manager z-index stack with identical behaviour.

HTML

The core contract: a trigger linked to its menu via aria-controls; auto-init binds every such trigger at DOMContentLoaded:

<button class="soma-button soma-dropdown2-trigger" aria-controls="menu">Menu</button>

<div id="menu" class="soma-dropdown2" aria-hidden="true">
  <ul>
    <li><a href="#">Edit</a></li>
    <li><a href="#">Duplicate</a></li>
    <li><a class="soma-disabled" aria-disabled="true" href="#">Archive</a></li>
  </ul>
</div>

Binding adds aria-haspopup="true" and manages aria-expanded on the trigger; the trigger also carries .soma-active while its menu is open.

Group longer menus into sections with uppercase headings and automatic dividers:

<div id="menu" class="soma-dropdown2" aria-hidden="true">
  <div class="soma-dropdown2-section">
    <strong class="soma-dropdown2-heading">Project</strong>
    <ul>
      <li><a href="#">Settings</a></li>
      <li><a href="#">Members</a></li>
    </ul>
  </div>
  <div class="soma-dropdown2-section">
    <strong class="soma-dropdown2-heading">Danger zone</strong>
    <ul>
      <li><a href="#">Delete project</a></li>
    </ul>
  </div>
</div>

Stateful items: author the initial state with .soma-checked + aria-checked; the component keeps both in sync from then on (radios are exclusive per <ul>):

<div id="view-menu" class="soma-dropdown2" aria-hidden="true">
  <ul>
    <li><a class="soma-dropdown2-checkbox soma-checked" aria-checked="true" href="#">Status column</a></li>
    <li><a class="soma-dropdown2-checkbox" aria-checked="false" href="#">Owner column</a></li>
  </ul>
  <ul>
    <li><a class="soma-dropdown2-radio soma-checked" aria-checked="true" href="#">Comfortable</a></li>
    <li><a class="soma-dropdown2-radio" aria-checked="false" href="#">Compact</a></li>
  </ul>
</div>

An icon-only trigger suppresses the chevron. Keep an accessible name:

<button class="soma-button soma-dropdown2-trigger soma-dropdown2-trigger-arrowless"
        aria-controls="menu" aria-label="More actions">
  <span class="soma-icon soma-icon-more"></span>
</button>

CSS classes

ClassEffect
.soma-dropdown2-triggerOn the trigger; links to the menu via aria-controls. Auto-init binds it. On a .soma-button it grows the chevron. Carries .soma-active + aria-expanded="true" while open.
.soma-dropdown2-trigger-arrowlessSuppresses the automatic chevron for icon-only triggers.
.soma-dropdown2The menu (solid surface, shadow). Hidden while aria-hidden="true"; shown in the top layer as a popover="auto", never leaving its DOM position.
.soma-dropdown2-section / -headingGrouping blocks with uppercase headings and dividers.
.soma-dropdown2-checkbox / -radioStateful items; .soma-checked shows the check glyph. Radios are exclusive per <ul>.
.soma-disabledInert item: clicks ignored, cursor skips it.
.soma-active (on li > a)The cursor item — managed by JS, shares the hover look.
.soma-notifications-panelAdd beside soma-dropdown2 for the bell panel (header/rows/footer; see reference).

JavaScript

Constructor

MemberDescription
Soma.dropdown2(input)Get or create the singleton for a trigger/menu pair. input may be the trigger element (resolved via its aria-controls), the menu element (resolved back to its trigger), or a CSS selector for either. Throws when nothing matches or neither class is present. The instance is keyed on the trigger — both entry points return the same one.

Instance methods

MemberDescription
.show()Open into the top layer (showPopover(); layer-manager fallback without the API), position, apply menu roles, pre-select the first item, arm scroll-close, dispatch show. No-op when already open.
.hide()Close (any path funnels through here: programmatic, light dismiss, Esc): clear the cursor, return focus to the trigger (Esc always; other paths only when focus was inside the menu), dispatch hide. No-op when already closed.
.toggle()show() or hide() depending on state.
.on(event, fn) / .off(event, fn)Subscribe / unsubscribe: 'show' and 'hide'. Listeners attach to the menu element.
.destroy()Unbind and restore the pre-init markup so a later Soma.dropdown2(…) re-binds fresh: closes first if open, then strips the generated ARIA (aria-haspopup/aria-expanded/aria-hidden), the show-applied menu roles, the popover attribute and the anchor wiring. Author-written attributes stay.
.isOpenBoolean state, readable at any time.

All methods except .destroy() return the instance, so calls chain. The underlying DOM events are bubbling CustomEvents (soma-dropdown-show / soma-dropdown-hide) dispatched on the menu element, so document-level delegated listening works. Note: on the no-popover fallback path the open menu is temporarily portaled under <body>, so delegated listeners scoped to a page region can miss events fired while open: listen on the document, the instance, or the menu itself. (With the Popover API the menu never leaves its DOM position.)

Every menu on this page is bound by auto-init — no wiring needed. The imperative API, when you do want it:

// Same instance whether you pass the trigger or the menu
const menu = Soma.dropdown2('#dd-plain');
menu.on('show', () => console.log('soma-dropdown-show'));
menu.show();

Act on menu item clicks; items are plain links, so ordinary click delegation on the menu works:

const dd = Soma.dropdown2('#row-actions');

document.getElementById('row-actions').addEventListener('click', (e) => {
  const item = e.target.closest('li > a');
  if (!item || item.classList.contains('soma-disabled')) return;
  e.preventDefault();
  runAction(item.dataset.action);
  dd.hide();                       // plain items don't auto-close on Enter/click
});

Read checkbox / radio state: the component maintains .soma-checked and aria-checked, so query them whenever you need the current selection:

Soma.dropdown2('#dd-checks').on('hide', () => {
  const visible = [...document.querySelectorAll('#dd-checks .soma-checked')]
    .map((a) => a.textContent.trim());
  applyColumns(visible);           // e.g. ['Status', 'Owner']
});

Bind a trigger rendered after page load (auto-init runs once, at DOMContentLoaded); the call is a get-or-create singleton, safe after any DOM update:

container.insertAdjacentHTML('beforeend', rowWithMoreMenu);
Soma.dropdown2(container.querySelector('.soma-dropdown2-trigger'));

Ajax menus: replace the menu's content freely after init. Roles are re-applied on every show, so the new items are announced correctly without re-binding:

const dd = Soma.dropdown2('#branch-menu');
dd.on('show', async () => {
  const branches = await fetch('/api/branches').then((r) => r.json());
  document.querySelector('#branch-menu ul').innerHTML =
    branches.map((b) => `<li><a href="#">${b}</a></li>`).join('');
});

The live examples above, exactly as this page wires them (only the events widget needs JS; everything else is auto-init):

const api = Soma.dropdown2('#dd-api');

// A second, non-trigger button opens the same menu imperatively
document.getElementById('dd-api-open')
  .addEventListener('click', () => api.show());

const out = document.getElementById('dd-events-out');
api.on('show', () => { out.textContent = 'soma-dropdown-show'; });
api.on('hide', () => { out.textContent = 'soma-dropdown-hide'; });