Popover
stimeo--popover
A non-modal floating panel toggled by a trigger, with focus management and dismissal.
The stimeo--popover controller implements the WAI-ARIA Dialog pattern run non-modally: no aria-modal, no focus trap, no scroll lock, so the background stays interactive. Clicking the trigger toggles aria-expanded and the panel's hidden attribute; opening moves focus to the first focusable element inside the panel. Esc and outside clicks close it and restore focus to the trigger, while tabbing focus out of the panel closes it without yanking focus back. Static placement is owned by this Playground's CSS; dynamic edge-collision avoidance is delegated to the opt-in stimeo-ui/positioning module. Behavior only — the look is yours.
Keyboard
| Key | Action |
|---|---|
| Enter / Space | Toggle the popover while the trigger is focused (native button). |
| Esc | Close the popover and return focus to the trigger. |
| Tab / Shift+Tab | Move within the panel; moving focus out of it closes the popover. |
<%# Markup for the popover demo.
A non-modal floating panel toggled by a trigger click. The panel is role="dialog"
with no aria-modal (the background stays interactive). Static placement lives in
this Playground's CSS; the library only handles open/close, focus, and dismiss. %>
<div class="popover" data-controller="stimeo--popover">
<button
class="demo-trigger"
type="button"
data-stimeo--popover-target="trigger"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="popover-panel"
data-action="click->stimeo--popover#toggle">
<%= t("components.popover.demo.trigger") %>
</button>
<div
class="popover__panel"
id="popover-panel"
role="dialog"
aria-label="<%= t("components.popover.demo.title") %>"
data-stimeo--popover-target="panel"
hidden>
<label class="popover__field">
<%= t("components.popover.demo.name_label") %>
<input type="text" name="display_name" />
</label>
<button
class="demo-trigger"
type="button"
data-action="click->stimeo--popover#close">
<%= t("components.popover.demo.done") %>
</button>
</div>
</div>
/*
* Presentation-only styles for the popover demo.
* The library shows/hides the panel by toggling its hidden attribute. Placement
* (directly below the trigger) is static and the consumer's CSS responsibility;
* connect the opt-in stimeo-ui/positioning module if you need dynamic flip/shift.
*/
.popover {
position: relative;
display: inline-block;
}
.popover__panel {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
z-index: 10;
min-width: 16rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background: var(--surface, var(--surface-card));
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.12);
}
/* Setting display: flex overrides the hidden attribute's default display:none, so
re-declare display:none while closed to honor the library's hidden toggle. */
.popover__panel[hidden] {
display: none;
}
.popover__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--fg);
}
.popover__field input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
font: inherit;
}
// popover opt-in positioning demo (consumer-side JS).
//
// The core controller (stimeo--popover) stays zero-dep and only handles open/close,
// focus, and dismiss. Viewport-edge flip/shift (collision avoidance) is optionally
// connected to the opt-in stimeo-ui/positioning module (@floating-ui/dom based) only
// when the consumer needs it. Here we watch the panel's hidden attribute: on open,
// start coordinate tracking with attachPositioning; on close, detach and clear the
// inline coordinates (falling back to demo.css's static placement).
import { attachPositioning } from 'stimeo-ui/positioning';
document.querySelectorAll('[data-controller~="stimeo--popover"]').forEach((root) => {
const trigger = root.querySelector('[data-stimeo--popover-target="trigger"]');
const panel = root.querySelector('[data-stimeo--popover-target="panel"]');
if (!trigger || !panel) return;
let detach = null;
const sync = () => {
if (!panel.hidden && !detach) {
// Opened: start tracking relative to the trigger (offset for the gap, flip/shift to
// avoid edges).
detach = attachPositioning(trigger, panel, {
placement: 'bottom-start',
offset: 8,
padding: 8,
});
} else if (panel.hidden && detach) {
// Closed: stop tracking, clear the inline coordinates, and return to the static CSS
// placement.
detach();
detach = null;
panel.style.position = panel.style.left = panel.style.top = '';
}
};
new MutationObserver(sync).observe(panel, { attributes: true, attributeFilter: ['hidden'] });
sync();
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--popover"
Targets
| Name | Description | Attribute |
|---|---|---|
trigger
required
|
The button that toggles the popover; carries aria-haspopup/aria-expanded and focus is restored here on close. |
data-stimeo--popover-target="trigger" |
panel
required
|
The non-modal role="dialog" panel shown/hidden via hidden; focus moves inside on open but is not trapped. |
data-stimeo--popover-target="panel" |
Actions
| Name | Description | Action |
|---|---|---|
close
|
Closes the panel and reflects the collapsed state on the trigger. | stimeo--popover#close |
open
|
Opens the panel, reflects state, and moves focus to the first focusable element inside (or the panel itself). | stimeo--popover#open |
toggle
|
Toggles the popover open/closed. | stimeo--popover#toggle |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-expanded |
Trigger | "true" while the popover is open, "false" when closed. |
hidden |
Panel | Removed when open, present when closed. |