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
| Pattern | Use it for |
|---|---|
| Drawer | Detail views beside a collection — a node inspector, row details. The page stays interactive. |
Drawer -wide | Richer inspectors (520px) such as logs, diffs, property groups. |
| Dialog2 | Tasks that must complete (or cancel) before returning — modal, blanket, focus trap. |
| Inline dialog | Small popover content anchored to its trigger, not a page-height panel. |
Examples
Keyboard & focus
| Key | Action |
|---|---|
| Esc | Close the drawer (topmost layer first); focus returns to the opener. |
| Tab / Shift+Tab | Normal 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
| Class | Effect |
|---|---|
.soma-drawer | The 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-wide | 520px variant. |
.soma-drawer-header / -title / -close | Header band: heading + dismiss button (any descendant .soma-drawer-close closes). |
.soma-drawer-content | The scrolling body band. |
.soma-drawer-footer | Pinned action band at the bottom. |
JavaScript
Constructor
| Member | Description |
|---|---|
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
| Member | Description |
|---|---|
.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'. |
.isOpen | Boolean 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'; });