-
Notifications
You must be signed in to change notification settings - Fork 41
example native window with tabs
Two ways to add tabs to a native window:
-
PHP-only (zero template boilerplate) — register the window, then register extra tabs with
openstation_register_window_tab(). The shell builds the tab strip in the window chrome and emits the<os-tabpanel>markup for you. Matches the legacy iframe-window DX where submenus auto-become tabs. -
Hand-rolled markup (still supported) — write the
<os-tabs>+<os-tabpanel>elements directly in your template callback. Auto-swap via<os-tabpanel>still handles pane visibility — you just author the tab strip yourself.
Pick option A for the common case (static tab list, one plugin owns the window). Pick option B when you need dynamic tabs, conditional panes, or custom layouts the auto-wrap can't express.
jorvy/jorvy.php:
<?php
/**
* Plugin Name: Jorvy with tabs
*/
defined( 'ABSPATH' ) || exit;
openstation_register_window( 'jorvy', array(
'title' => __( 'Jorvy', 'jorvy' ),
'main_tab_label' => __( 'Quotes', 'jorvy' ), // first tab label; falls back to `title`
'icon' => 'dashicons-star-filled',
'width' => 340,
'height' => 240,
'script' => 'jorvy-main',
'template' => function () {
?>
<p class="jorvy__quote"></p>
<cite class="jorvy__attr"></cite>
<?php
},
) );
openstation_register_window_tab( 'jorvy', array(
'value' => 'about',
'label' => __( 'About', 'jorvy' ),
'position' => 10,
'template' => function () {
?>
<os-stack gap="6">
<p><?php esc_html_e( 'A random Marvel quote, rotated every 10 seconds.', 'jorvy' ); ?></p>
<p><?php esc_html_e( 'Quotes are hard-coded — no network calls.', 'jorvy' ); ?></p>
</os-stack>
<?php
},
) );That's the entire tab-strip wiring. The shell produces this rendered template for the window body automatically:
<template id="os-native-window-jorvy">
<os-stack gap="12" padding="16">
<os-tabpanel for="main">
<p class="jorvy__quote"></p>
<cite class="jorvy__attr"></cite>
</os-tabpanel>
<os-tabpanel for="about" hidden>
<!-- About pane markup -->
</os-tabpanel>
</os-stack>
</template>The tab strip itself is deliberately absent from this markup: the shell builds it in the window chrome, directly under the title bar (the .os-window__tab buttons), from the same tab metadata — the same strip an admin-page window wears for its sub-pages, with the roving tabindex, arrow keys, and role="tablist" / role="tab" wiring for free. Tab changes surface as the os-window-tab-change CustomEvent on the window element (see docs/migration-window-tabs.md). Non-active panels arrive with hidden pre-stamped so first paint is correct regardless of upgrade order. The wrap's padding="16" is configurable via the main_tab_padding registration arg or the openstation_native_window_tab_wrap_padding filter.
Another plugin can attach tabs to Jorvy's window without coordinating — a good test case is a Stats tab that only exists when an analytics plugin is active:
// jorvy-stats/jorvy-stats.php
openstation_register_window_tab( 'jorvy', array(
'value' => 'stats',
'label' => __( 'Stats', 'jorvy-stats' ),
'position' => 20,
'script' => 'jorvy-stats',
'template' => function () {
?>
<os-stack gap="8">
<os-display size="xl" data-hook="quote-count">—</os-display>
<p><?php esc_html_e( 'Quotes shown this session.', 'jorvy-stats' ); ?></p>
</os-stack>
<?php
},
) );Deactivate the analytics plugin → the Stats tab disappears on the next window open. No shell reload. No coordination between the two plugins.
The shell still calls window.openStationNativeWindows.jorvy(body) once per window open — body contains the whole auto-generated tab tree. The plugin's JS scopes per-pane work via body.querySelector:
window.openStationNativeWindows.jorvy = function ( body ) {
const quote = body.querySelector( 'os-tabpanel[for="main"] .jorvy__quote' );
const attr = body.querySelector( 'os-tabpanel[for="main"] .jorvy__attr' );
const QUOTES = [
{ q: 'I am Iron Man.', by: 'Tony Stark' },
{ q: 'On your left.', by: 'Captain America' },
{ q: 'I love you 3000.', by: 'Morgan Stark' },
];
const render = () => {
const pick = QUOTES[ Math.floor( Math.random() * QUOTES.length ) ];
quote.textContent = '"' + pick.q + '"';
attr.textContent = '— ' + pick.by;
};
render();
const timer = setInterval( render, 10000 );
return () => clearInterval( timer );
};Tabs that ship static markup need no JS at all — the About pane in the example above is pure HTML.
Pass script on the tab registration. The shell enqueues the handle whenever the desktop shell loads (alongside the window's own script):
openstation_register_window_tab( 'jorvy', array(
'value' => 'stats',
'label' => __( 'Stats', 'jorvy-stats' ),
'template' => 'jorvy_stats_template',
'script' => 'jorvy-stats', // enqueued when the shell loads
) );The stats script can then wire its own pane in isolation (it only looks inside os-tabpanel[for="stats"]).
Useful when Option A is too prescriptive — e.g. you want panels wrapped in a custom card, or dynamic tabs that change based on server state the shell doesn't know about.
Earlier versions of the kit shipped <os-tabs> + <os-tab> but left pane management as homework — every multi-tab native window ended up copying the same os-tab-change listener and panel.hidden = … ladder. The <os-tabpanel> auto-swap removes that half.
<os-stack gap="12">
<os-tabs value="calc" label="Calculator mode">
<os-tab value="calc">Calc</os-tab>
<os-tab value="convert">Convert</os-tab>
</os-tabs>
<os-tabpanel for="calc">
<!-- calculator UI -->
</os-tabpanel>
<os-tabpanel for="convert">
<!-- converter UI -->
</os-tabpanel>
</os-stack>That is the whole wiring. Clicking a tab flips hidden on the matching <os-tabpanel> for you. The inactive pane gets aria-hidden="true", the active one is focusable (tabindex="0").
| Concern | Handled by | Notes |
|---|---|---|
aria-selected mirroring |
<os-tabs> |
Active tab gets true, others false. |
role="tab" / role="tablist"
|
<os-tabs> / <os-tab>
|
Applied in the component's connectedCallback. |
role="tabpanel" |
<os-tabpanel> |
Set on connect. |
hidden toggling |
<os-tabpanel> |
On every value change of the sibling <os-tabs>. |
aria-hidden mirroring |
<os-tabpanel> |
Matches hidden. |
| Focus ring when panel gains keyboard focus | <os-tabpanel> |
tabindex="0" + accent outline. |
os-tab-change event |
<os-tabs> |
Still fires — use it for side effects (telemetry, URL sync). |
my-plugin/my-plugin.php:
<?php
/**
* Plugin Name: Two-tab Demo
*/
defined( 'ABSPATH' ) || exit;
openstation_register_window( 'two-tab-demo', array(
'title' => __( 'Two-tab Demo', 'my-plugin' ),
'icon' => 'dashicons-layout',
'width' => 480,
'height' => 360,
'script' => 'two-tab-demo',
'template' => function () {
?>
<os-stack gap="12" style="padding:16px;">
<os-tabs value="hello" label="Demo mode">
<os-tab value="hello">Hello</os-tab>
<os-tab value="form">Form</os-tab>
</os-tabs>
<os-tabpanel for="hello">
<os-display size="xl">Hello, world.</os-display>
</os-tabpanel>
<os-tabpanel for="form">
<os-stack gap="10">
<os-text-field
label="<?php esc_attr_e( 'Name', 'my-plugin' ); ?>"
placeholder="<?php esc_attr_e( 'Who are you?', 'my-plugin' ); ?>"
autocomplete="name"
></os-text-field>
<os-number-field
label="<?php esc_attr_e( 'Favourite number', 'my-plugin' ); ?>"
value="42"
min="0"
max="999"
></os-number-field>
<os-select label="<?php esc_attr_e( 'Favourite colour', 'my-plugin' ); ?>" value="blue">
<os-option value="blue"><?php esc_html_e( 'Blue', 'my-plugin' ); ?></os-option>
<os-option value="green"><?php esc_html_e( 'Green', 'my-plugin' ); ?></os-option>
<os-option value="red"><?php esc_html_e( 'Red', 'my-plugin' ); ?></os-option>
</os-select>
<os-checkbox label="<?php esc_attr_e( 'Subscribe', 'my-plugin' ); ?>" value="subscribe"></os-checkbox>
</os-stack>
</os-tabpanel>
</os-stack>
<?php
},
) );
add_action( 'admin_enqueue_scripts', function () {
if ( ! function_exists( 'openstation_is_enabled' ) || ! openstation_is_enabled() ) {
return;
}
wp_enqueue_script(
'two-tab-demo',
plugin_dir_url( __FILE__ ) . 'two-tab-demo.js',
array( 'openstation' ),
'1.0.0',
true
);
} );my-plugin/two-tab-demo.js:
( function () {
window.openStationNativeWindows = window.openStationNativeWindows || {};
window.openStationNativeWindows[ 'two-tab-demo' ] = function ( body ) {
// The shell has already rendered tabs + panels from the PHP
// template. You only write JS for the per-pane behaviour you
// actually care about — NOT the tab/pane wiring.
body.querySelector( 'os-text-field' )
.addEventListener( 'os-input-commit', ( e ) => {
console.log( 'name:', e.detail.value );
} );
body.querySelector( 'os-number-field' )
.addEventListener( 'os-input-commit', ( e ) => {
console.log( 'fav number:', e.detail.value );
} );
// Optional: react to tab changes for telemetry, URL sync,
// whatever. The auto-swap already happened by the time this
// event fires.
body.querySelector( 'os-tabs' )
.addEventListener( 'os-tab-change', ( e ) => {
console.log( 'tab →', e.detail.value );
} );
};
} )();openstation_component( 'os-tabs', [ 'value' => 'calc' ], $tab_children );
openstation_component( 'os-stack', [ 'data-pane' => 'calc', 'gap' => 8 ], $calc_children );
openstation_component( 'os-stack', [ 'data-pane' => 'convert', 'gap' => 6, 'hidden' => true ], $convert_children );var activeTab = 'calc';
tabs.addEventListener( 'os-tab-change', function ( e ) {
activeTab = e.detail.value || 'calc';
calcPane.hidden = 'calc' !== activeTab;
convPane.hidden = 'convert' !== activeTab;
} );data-pane="…" was every plugin's private invention — three plugins picked three different conventions. ARIA roles had to be remembered and wired by hand.
<os-tabs value="calc">
<os-tab value="calc">Calc</os-tab>
<os-tab value="convert">Convert</os-tab>
</os-tabs>
<os-tabpanel for="calc">…</os-tabpanel>
<os-tabpanel for="convert">…</os-tabpanel>Zero JS for the tabs. Zero private conventions. role="tablist" / role="tab" / role="tabpanel" wired for free.
The auto-swap is opt-in — using <os-tabpanel> is what activates it. If you have an unusual layout (panels nested multiple levels deep, conditional pane types, a custom transition), keep listening for os-tab-change and swap whatever-you-like by hand. <os-tabs> fires the event in both modes.
-
<os-tabs>,<os-tab>,<os-tabpanel>— component reference (props/events via each class'sstatic help). -
<os-text-field>,<os-number-field>— the form primitives used in the example. -
docs/examples/register-icon.md— companion-plugin pattern for adding a wallpaper shortcut that opens the native window.
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