Popover
stimeo--popover
A floating panel toggled by its trigger. The background stays usable; focus and dismissal are handled.
A floating panel toggled by its trigger that leaves the rest of the page alone. It is not modal, so the background stays interactive while it is open. Opening moves focus to the first control inside the panel. Esc closes it and returns focus to the trigger. A click outside or tabbing beyond the popover's interaction area closes it without moving focus back. Static placement here is this demo's CSS. Flipping and shifting away from a screen edge is handled by the positioning module you can opt into.
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 naturally between its controls and panel; leaving the interaction area 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>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.events") %></span>
<span class="demo-readout__value" data-readout>—</span>
</p>
/*
* 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();
});
// Every move of the open state is reported, with a `reason` saying what drove it
// — a trigger, Escape, a click outside, or a call from your own code. A real app
// subscribes to mirror the state elsewhere or to send an analytics event; this
// demo writes the event name and its reason into the readout so both are visible
// while you interact. `reason: "api"` is the one to watch for: a close you asked
// for comes back to your own listener.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const stateReadout = demoStage.querySelector("[data-readout]");
const stateRoot = demoStage.querySelector("[data-controller~='stimeo--popover']");
for (const name of ["open", "close"]) {
stateRoot?.addEventListener(`stimeo--popover:${name}`, (event) => {
const reason = event.detail?.reason;
if (stateReadout) {
stateReadout.textContent = reason
? `stimeo--popover:${name} · reason: ${reason}`
: `stimeo--popover:${name}`;
}
});
}
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 when Escape closes it. |
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" |
Values
| Name | Attribute |
|---|---|
closeOnScroll
|
data-stimeo--popover-close-on-scroll-value |
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 |
Events
| Name | Description | Event |
|---|---|---|
close
|
Fires right after the panel closes; detail.reason says what moved it. |
stimeo--popover:close |
open
|
Fires right after the panel opens; detail.reason says what moved it. |
stimeo--popover:open |
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. |