agentsclimarketplace

Wp admin postbox sortable

Skill Lonsdale201/wp-agent-skills/wordpress/wp-admin-postbox-sortable

Wire up WordPress postboxes on custom plugin admin pages with collapse, drag sorting, Screen Options visibility, and core persistence. Covers `add_meta_box()`, `do_meta_boxes()`, the `postbox` script, `postboxes.add_postbox_toggles( pageId )`, the `.meta-box-sortables` / `.postbox` / `.hndle` DOM contract, and the two nonce fields plugins forget, `closedpostboxesnonce` and `meta-box-order-nonce`. Use when adding collapsible admin boxes, draggable metabox layouts, or Screen Options show/hide behavior to a custom plugin admin screen; use raw `jquery-ui-sortable` instead for non-postbox repeater rows.From its SKILL.md

Install
npx -y skills add Lonsdale201/wp-agent-skills --skill wp-admin-postbox-sortable

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 21 stars21 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

14.7 KB, ~3.4k tokens by cl100k_base, as published. Nobody here has run it

WordPress Admin Postboxes & Sortable

Use this skill when you need the familiar collapsible / draggable / Screen-Options-toggleable boxes that WordPress core uses on post edit and Dashboard, but on your own admin page or in your own draggable list. The most common failure mode is wiring up the HTML correctly, then watching the box collapse fine but never persist — because the nonce field is missing and the AJAX call silently fails.

When to use this skill

Trigger when ANY of the following is true:

  • The user is calling add_meta_box() on a custom screen (not just the regular post edit), or registering a custom screen and wants postbox behavior.
  • The user wants drag-and-drop reordering for a list of items in wp-admin (option rows, repeater fields, custom panels).
  • Code references postboxes.add_postbox_toggles, .meta-box-sortables, .postbox, .hndle, closedpostboxesnonce, meta-box-order-nonce, closedpostboxes_*, metaboxhidden_*, or meta-box-order_*.
  • The user says "the collapse/expand works but doesn't save" or "drag works but order doesn't persist" or "Screen Options checkboxes don't appear on my page".

The four pieces that MUST be in place

The postbox UI looks like a single component but it's actually four things wired together. Miss one and the behavior degrades silently. None of this is automatic on a custom admin page.

PieceOwnerFailure mode if missing
1. postbox script enqueuedYour admin_enqueue_scripts callbackNo collapse, no drag
2. Correct DOM (.meta-box-sortables > .postbox > .hndle)Your view templateSortable doesn't init, collapse classes don't bind
3. The two nonce fields in the formYour view templateAJAX persistence fails; state is not stored
4. postboxes.add_postbox_toggles( pageId ) called on DOM readyYour admin JSNo postbox behavior binds; sortable / collapse / Screen Options handlers never start

1. Enqueue the postbox script

postbox is registered with jquery-ui-sortable and wp-a11y as deps (wp-includes/script-loader.php:1439), and it lives in the footer. Enqueue it on your screen only — never globally.

add_action( 'admin_enqueue_scripts', static function ( string $hook_suffix ): void {
    if ( 'toplevel_page_myplugin' !== $hook_suffix ) {
        return;
    }

    wp_enqueue_script( 'postbox' );

    // Your bootstrap JS that calls postboxes.add_postbox_toggles().
    wp_enqueue_script(
        'myplugin-admin',
        plugins_url( 'assets/admin.js', MYPLUGIN_FILE ),
        array( 'postbox' ),
        MYPLUGIN_VERSION,
        array( 'in_footer' => true, 'strategy' => 'defer' )
    );
} );

2. The DOM contract

wp-admin/js/postbox.js initializes jQuery UI Sortable with items: '.postbox', handle: '.hndle', and connectWith: '.meta-box-sortables'. Your markup MUST follow these class names exactly, even if you style them away.

<div class="wrap">
    <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>

    <form method="post" action="">
        <?php
        // Both nonces MUST be present — see piece 3 below.
        wp_nonce_field( 'closedpostboxes', 'closedpostboxesnonce', false );
        wp_nonce_field( 'meta-box-order', 'meta-box-order-nonce', false );
        ?>

        <div id="poststuff">
            <div id="post-body" class="metabox-holder columns-2">
                <div id="post-body-content"><!-- main column content --></div>

                <div id="postbox-container-1" class="postbox-container">
                    <?php do_meta_boxes( 'toplevel_page_myplugin', 'side', $data_object ); ?>
                </div>

                <div id="postbox-container-2" class="postbox-container">
                    <?php do_meta_boxes( 'toplevel_page_myplugin', 'normal', $data_object ); ?>
                    <?php do_meta_boxes( 'toplevel_page_myplugin', 'advanced', $data_object ); ?>
                </div>
            </div>
            <br class="clear">
        </div>
    </form>
</div>

do_meta_boxes() outputs the .meta-box-sortables containers and reads the user's saved order from meta-box-order_toplevel_page_myplugin automatically. Register every box with this same actual screen ID. The safest registration point is load-{$hook_suffix}, where get_current_screen()->id is available; pass that value to add_meta_box(), do_meta_boxes(), and the JavaScript.

3. The two nonce fields — the most-forgotten step

Without these, wp_ajax_closed_postboxes() (wp-admin/includes/ajax-actions.php:1803) calls check_ajax_referer( 'closedpostboxes', 'closedpostboxesnonce' ) and dies. The request fails (usually visible in Network as -1 / 403), no user meta is written. The collapse toggles visibly, refresh the page, state is gone, and the developer blames "WordPress weirdness".

wp_nonce_field( 'closedpostboxes',  'closedpostboxesnonce',   false );
wp_nonce_field( 'meta-box-order',   'meta-box-order-nonce',   false );

These are emitted in core by wp-admin/edit-form-advanced.php, edit-form-comment.php, edit-link-form.php, nav-menus.php, and wp-admin/includes/dashboard.php. On YOUR custom page, you emit them — the page that contains the .postbox elements.

4. Initialize on DOM ready

The screen identifier you pass — pageId — is what gets sanitize_key'd server-side and used as the user-meta suffix. Pick something stable; never document.title or anything localized.

jQuery( function ( $ ) {
    postboxes.add_postbox_toggles( 'toplevel_page_myplugin', {
        pbshow: function ( id ) { /* optional: called when a box opens */ },
        pbhide: function ( id ) { /* optional: called when a box closes */ },
    } );
} );

After this call, the user gets click-to-collapse, drag-to-reorder, the move-up / move-down accessibility buttons, and Screen Options visibility. The three postbox states below persist to user meta. One/two-column layout is separate screen-layout state and requires the corresponding Screen Options setup.

Per-user persistence — the three storage keys

Replace $page with the sanitize_key'd page id you passed to add_postbox_toggles().

User meta keyContentsSet by
closedpostboxes_$pageArray of postbox IDs that are currently collapsedAJAX closed-postboxes action
metaboxhidden_$pageArray of postbox IDs hidden via Screen OptionsSame AJAX action; ALWAYS exempts submitdiv, linksubmitdiv, manage-menu, create-menu
meta-box-order_$pageMap of context => csv of postbox ids in orderAJAX meta-box-order action

do_meta_boxes() reads meta-box-order_$page and re-injects boxes in the user's saved order — you do nothing extra to make ordering "take effect" on next page load.

To reset a user's preferences (e.g. an "Reset layout" button on your page), delete_user_meta() on these three keys.

Screen Options registration (the hide/show checkboxes)

The Screen Options pane can list meta boxes registered against the actual current screen ID. add_menu_page()/add_submenu_page() creates the screen, but it does not repair a mismatched ID: a box registered as myplugin_page will not belong to a screen whose ID is toplevel_page_myplugin. Register on load-{$hook_suffix} and use get_current_screen()->id consistently.

If you want a custom "Layout" or "Show on screen" panel in Screen Options, use add_screen_option() from the load-{$hook_suffix} hook (covered separately by the WP-List-Table skill since it overlaps).

jQuery events you can hook

postbox.js triggers three events on document. Useful for syncing your own UI to the postbox state without polling.

jQuery( document )
    .on( 'postbox-toggled', function ( e, $postbox ) {
        // Fires after open/close. $postbox is a jQuery object.
    } )
    .on( 'postbox-moved', function ( e, $postbox ) {
        // Fires after a drag moves a box between sortable areas.
    } )
    .on( 'postboxes-columnchange', function () {
        // Fires when the user switches 1-column / 2-column layout via Screen Options.
    } );

Pattern: drag-reorderable list without postbox chrome

When the user wants drag-to-reorder for a list of plugin-specific rows (rules, repeater entries) WITHOUT the postbox look, skip postboxes.* and use jQuery UI Sortable directly. jquery-ui-sortable is registered in core; no enqueue magic needed beyond declaring it as a dep.

wp_enqueue_script(
    'myplugin-rules',
    plugins_url( 'assets/rules.js', MYPLUGIN_FILE ),
    array( 'jquery-ui-sortable', 'wp-a11y' ),
    MYPLUGIN_VERSION,
    array( 'in_footer' => true )
);

wp_localize_script( 'myplugin-rules', 'MyPluginRules', array(
    'restUrl' => esc_url_raw( rest_url( 'myplugin/v1/rules/order' ) ),
    'nonce'   => wp_create_nonce( 'wp_rest' ),
) );
jQuery( function ( $ ) {
    $( '#myplugin-rules-list' ).sortable( {
        handle: '.rule-handle',
        placeholder: 'rule-placeholder',
        update: function () {
            const order = $( this ).sortable( 'toArray', { attribute: 'data-rule-id' } );
            wp.apiFetch( {
                url: MyPluginRules.restUrl,
                method: 'POST',
                headers: { 'X-WP-Nonce': MyPluginRules.nonce },
                data: { order },
            } ).then( () => wp.a11y.speak( wp.i18n.__( 'Order saved.', 'myplugin' ) ) );
        },
    } );
} );

Two non-obvious bits worth keeping:

  • wp.a11y.speak() announces drag completion to screen readers. Core does this in postbox.js (save_state and save_order). Match the pattern.
  • Use data-* attributes on each row plus toArray({ attribute: 'data-rule-id' }) instead of relying on DOM ids — IDs collide more often than you'd think (especially in repeaters cloned from a template).

Critical rules

  • Emit both nonces when using core postbox persistence. If you emit only closedpostboxesnonce, drag-and-drop saves fail. If you emit only meta-box-order-nonce, collapse state saves fail. They're independent endpoints with independent nonces.
  • Never enqueue postbox globally. It binds click handlers on .postbox .hndle and .handlediv globally; if another plugin's UI happens to have a .postbox element, you'll bind their elements too.
  • The $page argument must be sanitize_key-safe. Core does sanitize_key( $page ) and wp_die(0) if it doesn't match — see ajax-actions.php:1813. Use myplugin_settings, never MyPlugin Settings.
  • Don't write your own AJAX handlers for these. Core already handles AJAX actions closed-postboxes and meta-box-order through wp_ajax_closed_postboxes() and wp_ajax_meta_box_order(). Reusing them is the whole point.
  • Do not reuse reserved core box IDs to force visibility. Core exempts submitdiv, linksubmitdiv, manage-menu, and create-menu from its hidden list, but colliding with those IDs couples plugin UI to unrelated core behavior. Render mandatory controls outside the optional postbox area or deliberately own the screen's visibility policy.
  • Postbox JS uses ajaxurl, the global injected by core on admin pages. If you're enqueueing on a screen where ajaxurl isn't defined (rare — really only the frontend), localize it yourself.

Common mistakes

// WRONG — runs before postbox.js loads (when postbox is in_footer, defer, or strategy=defer)
postboxes.add_postbox_toggles( 'toplevel_page_myplugin' );

// RIGHT — wait for DOM ready
jQuery( function () {
    postboxes.add_postbox_toggles( 'toplevel_page_myplugin' );
} );
// WRONG — wp_create_nonce returns the nonce string, not a hidden field
echo wp_create_nonce( 'closedpostboxes' );

// RIGHT — wp_nonce_field renders the <input type="hidden" id="closedpostboxesnonce" ...>
//        which is what postbox.js looks for via jQuery('#closedpostboxesnonce').val()
wp_nonce_field( 'closedpostboxes', 'closedpostboxesnonce', false );
// WRONG — enqueues on every admin page, binds handlers to other plugins' .postbox elements
add_action( 'admin_enqueue_scripts', static fn() => wp_enqueue_script( 'postbox' ) );

// RIGHT — only on your screen
add_action( 'admin_enqueue_scripts', static function ( string $hook_suffix ): void {
    if ( 'toplevel_page_myplugin' === $hook_suffix ) {
        wp_enqueue_script( 'postbox' );
    }
} );

Cross-references

  • See wp-plugin-assets-loading for the canonical pattern for conditional enqueueing on a specific $hook_suffix.
  • See wp-admin-list-table for Screen Options + per-user pagination preferences (overlapping user-meta storage pattern).
  • See wp-admin-form-controls when the metabox body itself contains color pickers, date pickers, or wp.codeEditor instances.

What this skill does NOT cover

  • The block-editor metaboxes (__back_compat_meta_box) compatibility layer. The whole "Gutenberg shoves metaboxes into a iframe" rabbit hole is its own topic.
  • Saving the content of a metabox on post save (save_post hook, nonce + capability check). Standard CPT scaffolding territory.
  • wp.media frame triggered from inside a metabox body — covered by wp-admin-media-frame.

References

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.