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
| Pattern | Use it for |
|---|---|
| Plain menu | Command lists behind a button — row actions, Export. |
| Sections | Longer menus with natural groupings; add uppercase headings. |
| Checkbox / radio items | View options: visible columns (checkbox, stays open), density (radio, exclusive). |
| Icon-only trigger | Row-level "more" menus where a chevron would be noise; add -arrowless. |
| Notifications panel | The navbar bell: header, unread rows with semantic icons, footer. See the example below. |
Examples
Keyboard navigation
| Key | Action |
|---|---|
| ↓ on the trigger | Open (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+Tab | Same as the arrows: the cursor moves with wrap, so Tab cannot escape into the page behind an open menu. |
| Home / End | Jump to the first / last item. |
| Enter | Invoke the cursor item (same as clicking it). |
| Esc | Close 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
| Class | Effect |
|---|---|
.soma-dropdown2-trigger | On 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-arrowless | Suppresses the automatic chevron for icon-only triggers. |
.soma-dropdown2 | The 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 / -heading | Grouping blocks with uppercase headings and dividers. |
.soma-dropdown2-checkbox / -radio | Stateful items; .soma-checked shows the check glyph. Radios are exclusive per <ul>. |
.soma-disabled | Inert item: clicks ignored, cursor skips it. |
.soma-active (on li > a) | The cursor item — managed by JS, shares the hover look. |
.soma-notifications-panel | Add beside soma-dropdown2 for the bell panel (header/rows/footer; see reference). |
JavaScript
Constructor
| Member | Description |
|---|---|
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
| Member | Description |
|---|---|
.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. |
.isOpen | Boolean 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'; });