Inline dialog

Summary

A click-opened popover anchored to its trigger, for rich content (definitions, small forms, link lists), with an arrow, flip-on-overflow, and native top-layer stacking (popover="auto"). Auto-init binds every trigger; the popover gets role="dialog" and the trigger aria-haspopup="dialog" + aria-expanded. For a few words of plain hint on hover, use a Tooltip instead.

When to use

PatternUse it for
Inline dialogContent with structure or links that should open on click and stay until dismissed.
Inline dialog + formOne-field micro-tasks in place (rename, quick filter) where a full dialog would be heavy.
TooltipA few words of hint on hover/focus — no structure, no links.
DropdownA menu of commands — items, not prose.

Examples

Basic

What counts as active?

Opens on click, stays until Esc, an outside click, or the trigger again.

Button trigger

Any element can be the trigger — the popover here holds a link list.

With a small form

One-field micro-task in place:

Current name: api-backend

Programmatic + events

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

Billing definition

Last event: (none)

Keyboard & focus

KeyAction
Enter / Space on the triggerToggle the popover (native link/button activation; the bound click handler runs).
EscClose (platform behaviour: the popover's close watcher unwinds one top-layer entry per press) and return focus to the trigger.

Opening does not move focus: the popover is not a focus trap and the trigger keeps focus, so a keyboard user can dismiss with Esc without losing their place. An outside click closes the popover without stealing focus from whatever was clicked. Clicking the open trigger closes the popover cleanly — never dismiss-then-reopen: engines that treat the trigger press as a light dismiss are guarded, and the follow-up click is swallowed.

Positioning & layers

The open popover is a native popover="auto" in the browser's top layer: it paints above every z-index on the page — and above an open modal <dialog>, where it stays interactive. Light dismiss (outside click) and Esc are platform behaviour; close-on-scroll stays a Soma behaviour (armed via requestAnimationFrame so the browser's own scroll-into-view nudge on open can't instantly close it). Without the Popover API everything falls back to the layer-manager z-index stack with identical behaviour.

The popover opens below the trigger, start-aligned (mirrored under RTL; no extra classes). When there is no room below and more above, it flips above and gains .soma-inline-dialog-bottom-arrow so the arrow moves to the bottom edge, still pointing at the trigger. When it would spill past the inline edge of the viewport it shifts inward. All automatic — nothing to configure. Where the engine supports CSS anchor positioning the placement (including the flips) is pure CSS (position-area + position-try-fallbacks) and the JS positioner stands down, only observing the resolved position to keep the arrow class right.

HTML

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

<a class="soma-inline-dialog-trigger" aria-controls="info" href="#">More</a>

<div id="info" class="soma-inline-dialog" aria-hidden="true">
  <div class="soma-inline-dialog-contents">
    <p><strong>Active</strong> means a request in the last 30 days.</p>
  </div>
  <span class="soma-inline-dialog-arrow"></span>
</div>

A button works as the trigger too, and the popover can hold a small form:

<button class="soma-button soma-inline-dialog-trigger" aria-controls="rename">Rename</button>

<div id="rename" class="soma-inline-dialog" aria-hidden="true">
  <div class="soma-inline-dialog-contents">
    <form class="soma-form" action="#">
      <div class="soma-field">
        <label class="soma-field-label" for="new-name">Service name</label>
        <input class="soma-input soma-input-full" id="new-name" type="text" />
      </div>
      <div class="soma-form-actions">
        <button class="soma-button soma-button-primary soma-button-compact" type="button">Save</button>
      </div>
    </form>
  </div>
  <span class="soma-inline-dialog-arrow"></span>
</div>

Place popovers at the end of <body>, outside the page layout. Binding fills the ARIA gaps: role="dialog" on the popover (an explicit role wins), aria-haspopup="dialog" on the trigger, and aria-expanded managed from then on.

CSS classes

Class / attributeEffect
.soma-inline-dialog-triggerThe opener, linked via aria-controls. Auto-init binds it. Carries .soma-active + aria-expanded="true" while its popover is open.
.soma-inline-dialogThe popover (200–320px, solid surface, shadow). Hidden while aria-hidden="true"; shown in the top layer as a popover="auto". CSS anchor positioning owns placement where supported; the JS positioner elsewhere.
.soma-inline-dialog-contentsThe padded body.
.soma-inline-dialog-arrowThe anchored arrow: a rotated square peeking out of the top edge.
.soma-inline-dialog-bottom-arrowAdded by JS on the popover when it flips above its trigger; moves the arrow to the bottom edge.

JavaScript

Constructor

MemberDescription
Soma.inlineDialog(input)Get or create the singleton for a trigger/popover pair. input may be the trigger element (popover resolved via aria-controls), the popover 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, arm scroll-close, dispatch show. Focus stays on the trigger. No-op when already open.
.hide()Close (any path funnels through here: programmatic, light dismiss, Esc), return focus to the trigger (Esc always; other paths when focus was inside the popover), 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 popover element.
.destroy()Unbind and restore the pre-init markup so a later Soma.inlineDialog(…) re-binds fresh: hides first if open, then strips the generated ARIA (aria-haspopup/aria-expanded/aria-hidden/role), 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-inline-dialog-show / soma-inline-dialog-hide) dispatched on the popover element, so document-level delegated listening works.

Close on save. Auto-init already binds the trigger; grab the instance for imperative control:

const popover = Soma.inlineDialog('#rename');   // trigger or popover both work

document.getElementById('save').addEventListener('click', () => {
  submitRename();
  popover.hide();
});

Listen delegated — the events bubble:

document.addEventListener('soma-inline-dialog-show', (e) => {
  console.log('popover opened:', e.target.id);
});

Refresh content just before every open:

Soma.inlineDialog('#usage-popover').on('show', () => {
  document.querySelector('#usage-popover .soma-inline-dialog-contents')
    .textContent = formatUsage(currentUsage());
});

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

document.body.insertAdjacentHTML('beforeend', renderedPopover);
row.insertAdjacentHTML('beforeend', renderedTrigger);
Soma.inlineDialog(row.querySelector('.soma-inline-dialog-trigger'));

The live examples above, exactly as this page wires them:

// Rename form — close on save
const popover = Soma.inlineDialog('#ind-form');
document.getElementById('ind-save').addEventListener('click', () => {
  const name = document.getElementById('ind-name').value.trim();
  if (name) document.getElementById('ind-form-out').textContent = name;
  popover.hide();
});

// Programmatic + events
const events = Soma.inlineDialog('#ind-events');
document.getElementById('ind-toggle')
  .addEventListener('click', () => events.toggle());
const out = document.getElementById('ind-events-out');
events.on('show', () => { out.textContent = 'soma-inline-dialog-show'; });
events.on('hide', () => { out.textContent = 'soma-inline-dialog-hide'; });