Skip to content

example window action

github-actions[bot] edited this page Aug 25, 2026 · 1 revision

Add a row to a window's ⋯ menu

Experimental — see javascript-reference.md.

The ⋯ menu in every window's title bar is where infrequent, wordy, per-window verbs live — "Open in browser tab", "Open on startup", "Reload". wp.os.registerWindowAction() lets your plugin put a row there too.

Reach for a title-bar button (registerTitleBarButton) instead when the user will want it constantly. The menu is for the things that would be clutter as a permanent icon.

The minimum

wp.os.ready( () => {
    wp.os.registerWindowAction( {
        id: 'my-plugin/copy-link',
        label: 'Copy link to this screen',
        icon: 'dashicons-admin-links',
        onSelect: ( win ) => {
            navigator.clipboard.writeText( win.getCurrentUrl() );
            wp.os.showToast( { message: 'Link copied' } );
        },
        owner: 'my-plugin-shell',
    } );
} );

owner is the WordPress script handle. Set it and the row disappears by itself when your plugin is deactivated, with no reload.

One row that expresses a toggle

label, icon and isVisible may each be a function of the window, and they are re-read every time the menu opens. That is what lets a single row say what it will actually do right now:

wp.os.registerWindowAction( {
    id: 'my-plugin/pin',
    label: ( win ) => ( isPinned( win.id ) ? 'Unpin from top' : 'Pin to top' ),
    icon: ( win ) => ( isPinned( win.id ) ? 'dashicons-unlock' : 'dashicons-sticky' ),
    onSelect: ( win ) => togglePin( win.id ),
    owner: 'my-plugin-shell',
} );

Two rows — "Pin" and "Unpin" — would imply a window could be both at once. One row that answers "what does this do?" describes the situation honestly. This is exactly how the Electron Adapter's "Send to your Mac" row becomes "Bring back into OpenStation".

Showing the row only where it applies

wp.os.registerWindowAction( {
    id: 'my-plugin/lint-page',
    label: 'Check this page for issues',
    icon: 'dashicons-search',
    // Iframe windows only — a native window has no admin page to check.
    isVisible: ( win ) => ! win.config.native,
    onSelect: ( win ) => runLinter( win.getCurrentUrl() ),
    owner: 'my-plugin-shell',
} );

isVisible is re-read per open, so a row can appear and disappear with whatever it depends on — a capability, a connection, the page the window has navigated to — without your plugin re-registering anything.

A checkbox row for a per-window preference

A verb runs and the menu closes. A checkbox reports a setting the window either has or does not, and stays open when clicked so the user watches the tick land:

const KEY = 'my-plugin/show-gridlines';

wp.os.registerWindowAction( {
    id: 'my-plugin/show-gridlines',
    label: 'Show gridlines',
    checkable: true,
    checked: () => localStorage.getItem( KEY ) === '1',
    isVisible: ( win ) => win.id === 'my-plugin-canvas',
    onSelect: () => {
        const next = localStorage.getItem( KEY ) === '1' ? '0' : '1';
        localStorage.setItem( KEY, next );
        repaintCanvas();
    },
    owner: 'my-plugin-shell',
} );

checked is asked, never told. It runs on every menu open, so you persist the value and repaint nothing — and the row cannot disagree with your plugin for longer than one open, however the value changed (a second window, a settings panel, a REST response landing late). This is how the built-in Corkboard's "Show pins" works.

closeOnSelect overrides the defaults in either direction: false on a verb keeps the menu up, true on a checkbox dismisses it after the flip.

Checkbox or relabelling verb? Use the relabelling label above when the two states are two places the window can be — the row names the move. Use a checkbox when they are one setting: a tick says "there is a thing here, and it is currently off", which a label reading "Show gridlines" alone cannot.

Ordering

order sorts your row against other plugins' rows; the built-in items always come first. Default is 100.

order: 60,   // earlier than most

What the framework guarantees

  • The menu closes before onSelect runs for a verb row, so a handler that opens a dialog or navigates is not competing with a still-painted popover. A checkbox instead flips its tick optimistically and leaves the menu open.
  • A throwing resolver or handler is contained. A row whose label or isVisible throws simply does not appear; a checked that throws paints unchecked rather than dropping the row; a handler that throws is logged. The ⋯ menu is shared surface — one plugin's bug must not cost the user their "Reload".
  • Registration is validated loudly. A bad id, a missing onSelect, a non-function isVisible, or checkable without checked throws a RegistrationError naming the field, at registration time, rather than silently painting nothing.

Deciding when the menu opens

isVisible is re-read per open, but it is synchronous — it cannot go and ask something. HOOKS.WINDOW_MENU_OPENED can:

wp.os.hooks.addAction( wp.os.HOOKS.WINDOW_MENU_OPENED, 'my-plugin/probe', () => {
    void isCompanionAppRunning().then( ( running ) => {
        if ( running ) {
            wp.os.registerWindowAction( { /* … */ } );
        }
    } );
} );

An open menu repaints when the registry changes, so a row registered from that callback appears under the user's pointer rather than on their next click. This is how the Electron adapter notices an app that started after the page loaded — no refresh needed.

Removing it

wp.os.unregisterWindowAction( 'my-plugin/pin' );

And to see what is registered:

wp.os.listWindowActions();   // sorted by `order`

Going away cleanly when your plugin is deactivated

Registering from JS is enough to get the row on screen. To have it leave on deactivation — without the user reloading the page — declare your script server-side and tag each action with the same handle:

add_action( 'admin_enqueue_scripts', function () {
    wp_register_script(
        'my-plugin-window-actions',
        plugins_url( 'js/window-actions.js', __FILE__ ),
        array( 'openstation' ),
        '1.0.0',
        true
    );
    wp_enqueue_script( 'my-plugin-window-actions' );
} );
openstation_register_window_action_script( 'my-plugin-window-actions' );
wp.os.registerWindowAction( {
    id: 'my-plugin/pin',
    label: 'Pin to top',
    onSelect: ( win ) => pin( win.id ),
    owner: 'my-plugin-window-actions',   // same handle
} );

Now the handle rides in the live-refresh payload the shell diffs: activating your plugin loads the script and the row appears in the next menu that opens, and deactivating it sweeps out every action carrying that owner.

Skip the PHP call and owner has nothing to match against — the row stays until the next page reload. Harmless, and the reason a plugin written before this existed still behaves.

Home

Guides

Migration notes

Examples

All examples

Clone this wiki locally