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
| Pattern | Use it for |
|---|---|
| Inline dialog | Content with structure or links that should open on click and stay until dismissed. |
| Inline dialog + form | One-field micro-tasks in place (rename, quick filter) where a full dialog would be heavy. |
| Tooltip | A few words of hint on hover/focus — no structure, no links. |
| Dropdown | A menu of commands — items, not prose. |
Examples
Keyboard & focus
| Key | Action |
|---|---|
| Enter / Space on the trigger | Toggle the popover (native link/button activation; the bound click handler runs). |
| Esc | Close (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 / attribute | Effect |
|---|---|
.soma-inline-dialog-trigger | The opener, linked via aria-controls. Auto-init binds it. Carries .soma-active + aria-expanded="true" while its popover is open. |
.soma-inline-dialog | The 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-contents | The padded body. |
.soma-inline-dialog-arrow | The anchored arrow: a rotated square peeking out of the top edge. |
.soma-inline-dialog-bottom-arrow | Added by JS on the popover when it flips above its trigger; moves the arrow to the bottom edge. |
JavaScript
Constructor
| Member | Description |
|---|---|
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
| Member | Description |
|---|---|
.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. |
.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-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'; });