Drawer

Summary

A slide-over side panel from the inline-end edge: the non-modal sibling of dialog2, built for inspecting one item from a collection (a graph node, a table row) while the collection stays visible and clickable. There is no blanket and no focus trap: Soma closes it on Esc or a click outside, focus moves into the drawer on show and returns to the opener on hide. While open it rides the browser's top layer (popover="manual") so it paints above open dialogs. Slides in RTL-aware; the slide is skipped under reduced motion.

When to use

PatternUse it for
DrawerDetail views beside a collection — a node inspector, row details. The page stays interactive.
Drawer -wideRicher inspectors (520px) such as logs, diffs, property groups.
Dialog2Tasks that must complete (or cancel) before returning — modal, blanket, focus trap.
Inline dialogSmall popover content anchored to its trigger, not a page-height panel.

Examples

Basic open / close

Esc, a click outside, or its own × closes it; focus returns to this button.

Wide

soma-drawer-wide, 520px:

Room for tables, logs and diffs.

Anchored to a trigger

.toggle(triggerEl):

Passing the opener excludes it from "outside", so the same button cleanly opens and closes.

Non-modal

No blanket; the page stays interactive:

Open the drawer, then click the counter: the click still lands (it increments) and, being outside, it closes the drawer — nothing is swallowed by a blanket.

Events

.on('show') / .on('hide'):

Last event: (none)

Keyboard & focus

KeyAction
EscClose the drawer (topmost layer first); focus returns to the opener.
Tab / Shift+TabNormal document order: there is no trap; the drawer is non-modal, so focus can move out into the page.

On .show() focus moves to the first focusable element inside the drawer (a drawer with none gets tabindex="-1" and is focused itself). On hide, focus returns to the previously focused element only when it still sits inside the drawer, which covers Esc and the × button without stealing focus from an outside click's target.

Layer behaviour

The open drawer is a native popover="manual" in the browser's top layer — above every z-index on the page and above open modal dialogs. Manual is the only fit for the drawer's contract: no platform light dismiss and no page inertness, so the page genuinely stays interactive while it is open (the non-modal contract — try the counter demo above). Esc and outside-click stay Soma behaviour: a mousedown anywhere outside the drawer closes it (topmost overlay first: a dropdown opened inside the drawer closes before the drawer does). The trigger passed to .show(trigger) is excluded from "outside", so a toggle button doesn't close-and-instantly-reopen. Without the Popover API the layer manager assigns a runtime z-index instead; behaviour is identical.

HTML

Place the drawer at the end of <body>, outside your page layout, hidden by default. Give it an aria-label (or aria-labelledby); the drawer's name is not auto-derived:

<aside class="soma-drawer" id="node-panel" aria-hidden="true" aria-label="Node details">
  <header class="soma-drawer-header">
    <h2 class="soma-drawer-title">api-backend</h2>
    <button class="soma-drawer-close" aria-label="Close">
      <span class="soma-icon soma-icon-close"></span>
    </button>
  </header>
  <div class="soma-drawer-content">…</div>
  <footer class="soma-drawer-footer">
    <button class="soma-button soma-button-compact">Logs</button>
  </footer>
</aside>

The wide variant adds one class:

<aside class="soma-drawer soma-drawer-wide" id="history" aria-hidden="true"
       aria-label="Deployment history">…</aside>

Binding fills the ARIA gaps: role="complementary" and aria-hidden="true" are added when missing (an explicit role in the markup wins). The header, content and footer bands are all optional. Any descendant .soma-drawer-close closes on click, wherever it sits.

RTL: the drawer docks to the inline-end edge: under dir="rtl" it sits on, and slides in from, the left, with no extra classes (try the direction cycler in the corner, or see Themes).

CSS classes

ClassEffect
.soma-drawerThe slide-over panel, inline-end edge, 380px. Hidden state keeps it in the layout but slid out, so the open transition can play; the slide is skipped under prefers-reduced-motion.
.soma-drawer-wide520px variant.
.soma-drawer-header / -title / -closeHeader band: heading + dismiss button (any descendant .soma-drawer-close closes).
.soma-drawer-contentThe scrolling body band.
.soma-drawer-footerPinned action band at the bottom.

JavaScript

Constructor

MemberDescription
Soma.drawer(elOrSelector)Get or create the singleton instance for the element. Throws when nothing matches or the element lacks the soma-drawer class. Imperative only — there is no auto-init trigger attribute.

Instance methods

MemberDescription
.show(triggerEl?)Open. Pass the opening trigger so its clicks don't count as "outside" while the drawer is open (a toggle button would otherwise close-and-reopen). Records the focused element, enters the top layer, focuses the first focusable, dispatches show. No-op when already open.
.hide()Close: leave the top layer, restore focus (only when it still sits inside the drawer), dispatch hide. No-op when already closed.
.toggle(triggerEl?)show(triggerEl) or hide() depending on state.
.destroy()Unbind and restore the pre-init markup: closes the drawer first if open (the final hide still fires), removes the close-button listener, and strips only component-generated attributes (popover, plus aria-hidden/role when the component added them; author-supplied attributes stay). The element stays in the DOM; for fragments about to be re-rendered.
.on(event, fn) / .off(event, fn)Subscribe / unsubscribe: 'show' and 'hide'.
.isOpenBoolean state, readable at any time.

All methods return the instance, so calls chain. The underlying DOM events are bubbling CustomEvents (soma-drawer-show / soma-drawer-hide) dispatched on the drawer element, so a document-level addEventListener works for delegated listening.

Open from a row click, passing the row as the trigger:

const inspector = Soma.drawer('#node-panel');

table.addEventListener('click', (e) => {
  const row = e.target.closest('tr[data-service]');
  if (!row) return;
  renderDetails(row.dataset.service);     // fill .soma-drawer-content
  inspector.show(row);                    // row clicks won't close it
});

Mirror the open state onto a toggle button: the aria-expanded stays correct no matter how the drawer was closed (Esc, outside click, ×):

const drawer = Soma.drawer('#node-panel');
const btn = document.getElementById('toggle-inspector');

btn.addEventListener('click', (e) => drawer.toggle(e.currentTarget));
drawer.on('show', () => btn.setAttribute('aria-expanded', 'true'));
drawer.on('hide', () => btn.setAttribute('aria-expanded', 'false'));

Listen delegated — the events bubble:

document.addEventListener('soma-drawer-hide', (e) => {
  console.log('drawer closed:', e.target.id);   // e.g. persist panel state
});

Bind a drawer rendered after page load; the call is a get-or-create singleton, safe after any DOM update:

document.body.insertAdjacentHTML('beforeend', renderedDrawerMarkup);
Soma.drawer('#late-panel').show();

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

const drawer = Soma.drawer('#demo-drawer');
const wide = Soma.drawer('#demo-drawer-wide');

// Basic — pass the opener so its clicks don't count as "outside"
document.getElementById('open-drawer')
  .addEventListener('click', (e) => drawer.show(e.currentTarget));
document.getElementById('open-drawer-wide')
  .addEventListener('click', (e) => wide.show(e.currentTarget));

// Toggle button anchored to the drawer, aria-expanded kept in sync
const toggleBtn = document.getElementById('toggle-drawer');
toggleBtn.addEventListener('click', (e) => drawer.toggle(e.currentTarget));
drawer.on('show', () => toggleBtn.setAttribute('aria-expanded', 'true'));
drawer.on('hide', () => toggleBtn.setAttribute('aria-expanded', 'false'));

// Non-modal proof: the counter keeps working while the drawer is open
document.getElementById('open-drawer-nm')
  .addEventListener('click', (e) => drawer.show(e.currentTarget));
let clicks = 0;
const counter = document.getElementById('nm-counter');
counter.addEventListener('click', () => {
  clicks += 1;
  counter.textContent = `Clicked ${clicks} time${clicks === 1 ? '' : 's'}`;
});

// Events readout
document.getElementById('open-drawer-ev')
  .addEventListener('click', (e) => drawer.show(e.currentTarget));
const out = document.getElementById('drawer-events-out');
drawer.on('show', () => { out.textContent = 'soma-drawer-show'; });
drawer.on('hide', () => { out.textContent = 'soma-drawer-hide'; });