-
Notifications
You must be signed in to change notification settings - Fork 41
example os flyout
For the recurring "auxiliary card that slides in inside a window" pattern. Account panels, filter sidebars, navigation drawers, settings overlays — each one had ~120 LOC of bespoke wiring before. The component bakes the spec the os-tumblr prototype validated by hand: containment, margins from every edge, focus capture-and-restore (with preventScroll), focus trap, click-outside-via-pointerdown scoped to the window, no backdrop by default. Stable.
<os-flyout> is position: absolute and lives inside the window body, which the framework styles position: relative; overflow: hidden. The flyout cannot escape its window — close one window, the flyout tears down with it. There is no viewport-fixed drawer, no z-index: 9999 shouting match. The card sits at z-index: 10 above the window's main content, below the shell's chrome.
Margins from every edge: inset-block: 64px 14px; inset-inline-end: 14px; — the title bar stays visible above, the trailing edges keep gutters so the card reads as a floating panel inside the window, not as a panel pinned to the viewport edge.
<!-- The flyout — a sibling of your window's main content. -->
<os-flyout id="account" placement="end" aria-label="Account">
<header style="padding: 16px;">
<h2 style="margin: 0;">My Account</h2>
</header>
<main style="padding: 16px;">
<p>Username: <strong>jorvy</strong></p>
<button id="signout">Sign out</button>
<button data-flyout-close>Close</button>
</main>
</os-flyout>
<!-- The trigger — anywhere else in the window's body. -->
<button id="open-account-btn">Account</button>const flyout = document.getElementById( 'account' );
const trigger = document.getElementById( 'open-account-btn' );
trigger.addEventListener( 'click', () => {
flyout.setAttribute( 'open', '' );
} );
flyout.addEventListener( 'os-flyout-dismiss', ( e ) => {
// e.detail.reason: 'escape' | 'pointer' | 'close-button' | 'api'
if ( e.detail.reason !== 'api' ) {
analytics.track( 'flyout.user-dismiss', { reason: e.detail.reason } );
}
} );That's the whole UX. The component:
- Captures the trigger (the focused element when
openflips on) and restores focus to it on dismiss — both with{ preventScroll: true }so the off-screen-during-transition target doesn't jitter the window. - Moves focus into the panel on open. Tab cycles within; Shift+Tab wraps. The trigger is unreachable until close.
- Listens for
pointerdownevents on the window body (not the document). A click anywhere outside the panel but inside the window dismisses with reason'pointer'. Clicking the trigger itself is ignored — its own click handler decides. - Listens for Escape on
document. When the flyout is open, Escape dismisses with reason'escape'and consumes the event. - Detects buttons inside the panel marked
data-flyout-close. Clicking dismisses with reason'close-button'. - When external code removes
openimperatively, fires reason'api'.
placement |
Anchor | Slide direction |
|---|---|---|
end (default)
|
inline-end edge (right in LTR, left in RTL) — inset-block: 64px 14px; inset-inline-end: 14px;
|
from inline-end |
start |
inline-start edge | from inline-start |
top |
block-start edge | from above |
The inset-inline-* properties make start / end direction-aware automatically — RTL flips the side AND the slide direction. prefers-reduced-motion: reduce snaps the panel in/out instantly.
Default scope="window" walks up the DOM until it finds a .os-window__body. That's the right setting for any flyout opened inside a OpenStation native window. Two escape hatches:
-
scope="parent"— listen on the immediate parent element. Useful for flyouts mounted inside ad-hoc containers (component showcase, isolated stories). -
scope="document"— listen ondocument.body. Last-resort for full-page contexts.
<os-flyout placement="end" scope="window">…</os-flyout>
<os-flyout placement="end" scope="parent">…</os-flyout>Custom-property hooks for the few values plugins typically override:
os-flyout {
--os-ui-flyout-bg: #ffffff; /* card surface */
--os-ui-flyout-fg: var(--os-fg); /* text colour */
--os-ui-flyout-shadow: 0 16px 48px rgba(0, 25, 53, 0.4);
--os-ui-flyout-backdrop: rgba(0, 0, 0, 0.4); /* default: transparent */
}The default --os-ui-flyout-backdrop: transparent keeps the flyout additive — clicks anywhere outside the panel dismiss, but the rest of the UI stays interactive-looking. Set a non-transparent value when the plugin wants window-scoped modality (the rest of the window dims, but other windows stay live — distinct from <os-confirm-dialog>'s viewport-modal pattern).
The os-flyout-dismiss event fires on every close — including when external code removes open imperatively. The detail.reason discriminator lets subscribers branch:
flyout.addEventListener( 'os-flyout-dismiss', ( e ) => {
switch ( e.detail.reason ) {
case 'escape':
case 'pointer':
case 'close-button':
// user-driven dismissal
break;
case 'api':
// your own code closed it
break;
}
} );This means you can write the close-handler once and have it fire whether the user pressed Escape, clicked outside the panel, hit a data-flyout-close button, or you ran flyout.removeAttribute('open') from a sibling button click.
- Defaults
role="dialog". Setaria-labeloraria-labelledbyso screen readers announce the flyout's purpose. -
inertis applied to the host while closed — Tab navigation and screen readers skip the off-screen content. - Focus moves into the panel on open with
{ preventScroll: true }(per the spec, this is the gotcha that bites every hand-rolled implementation: without it, the off-screen-during-transition target gets scrolled into view and the whole window jitters). - Focus restores to the trigger on close, also with
preventScroll: true. -
prefers-reduced-motion: reducedisables the slide animation.
When the component disconnects (the host is removed from the DOM, or its window closes), every listener detaches: the document-level Escape, the scope-root pointerdown, the host-level Tab trap, the host-level click handler. A window-close that drops the flyout host doesn't leak handlers.
-
<os-confirm-dialog>— the modal Yes/No sibling for "block everything until the user decides". -
layout-primitives.md—<os-*>layout components for the panel content.
This wiki is generated from the docs/ directory — edits made here are overwritten by the next sync.
To change a page, open a pull request against docs/.
Guides
- Development guide
- Releasing openstation
- Agents security model
- API Index
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Native Desktop Host — Experimental
- Desktop themes
- Dock customization — two registries, one mental model
- The event-driven framework
- Files on the Desktop
- Folder sharing
- Getting Started
- Hooks Reference
- Icons
- JavaScript Reference
- The Living Tree — algorithm definition
- Mio
- Native Windows & Framework Interop
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration: window, wallpaper and widget bundles load on demand
- Migration — the navigation model
- Migration: a native window's tabs move to the window chrome
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- Custom arrange-menu action
- Open a child window its owner can't cover
- Style a specific admin page inside the iframe
- Code Blue — register your plugin's log file
- Open a file in the Code editor (deep-link from any window)
- Connect to a window — title-bar button + iframe pub/sub
- Content changes — live-refresh every window listing your type
- Custom window chrome (Experimental)
- Register a custom unfocused-window effect
- Example: render a data table
- Real file storage — react to uploads, gate policy, share from PHP
- React to a window being set free onto the real desktop
- Cross-window devtools — instrumentation primitives
- Add a dock item with a badge
- Decorate the dock without forking the renderer
- Replace the dock rail entirely
- Retune the Drafts widget's AI writing assistant
- Gate OpenStation by role
- Iframe-initiated window opens
- Build a feed reader without the bookkeeping
- Inject data into openStationConfig
- Render a list without losing clicks — renderKeyedList()
- Example: layout primitives (body → panel → row → col)
- Use <os-*> components from a plugin that ships as a zip
- Restyle and drive Mio
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- Add an action button to a WP Explorer preview pane
- Example: native Posts window
- Example: native window with tabs
- Native windows
- Customize note → post conversion
- Send a notification
- OAuth relay — connect to an external service
- OS-file drop
- <os-flyout> — window-scoped sliding card
- Plugins window — extras
- Track who's around — wp.os.presence
- Example: progress bar
- PWA install — surface your own button
- React to window events
- Example: extend the Trash
- Register a slash-command
- Register a desktop theme from a plugin
- Register a game
- Example: register a desktop icon (Jorvy)
- Register a wallpaper
- Register a widget
- Related entities — extend the title bar's "Related" menu
- The native-window render ctx
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Accept drops on your desktop icon
- Give a tile two icons, one per state
- Add a row to a window's ⋯ menu
- Example: window activity & the status ring
- Window controls
- Subscribe to window lifecycle events
- Window links — relate windows and restyle the ties (Experimental)
- Window loading state — spinner overlay & ready signal
- Show a banner at the top of a window
- Pulse a window's icon — Window.requestAttention()
- Register a custom window reveal
- Window slots
- Window themes
- Native window with bundle-bound config