Claude Skill

wp-admin-postbox-sortable

Wire up WordPress postboxes on custom plugin admin pages with

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-wordpress_wp-admin-postbox-sortable-52f6020.zip · 5 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/wordpress/wp-admin-postbox-sortable
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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.

Piece Owner Failure mode if missing
1. postbox script enqueued Your admin_enqueue_scripts callback No collapse, no drag
2. Correct DOM (.meta-box-sortables > .postbox > .hndle) Your view template Sortable doesn't init, collapse classes don't bind
3. The two nonce fields in the form Your view template AJAX persistence fails; state is not stored
4. postboxes.add_postbox_toggles( pageId ) called on DOM ready Your admin JS No 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.

In WordPress 7.1, core-generated postboxes also receive a named role="region", an ID on the .hndle heading, and wp_get_tooltip() wrappers for move/show-hide controls. The postbox handle depends on wp-tooltip. Prefer do_meta_boxes() so these semantics update with core. If you hand-copy postbox markup, you own the region name, heading/control association, tooltip assets, and future drift.

Per-user persistence — the three storage keys

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

User meta key Contents Set by
closedpostboxes_$page Array of postbox IDs that are currently collapsed AJAX closed-postboxes action
metaboxhidden_$page Array of postbox IDs hidden via Screen Options Same AJAX action; ALWAYS exempts submitdiv, linksubmitdiv, manage-menu, create-menu
meta-box-order_$page Map of context => csv of postbox ids in order AJAX 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

Files (wp-agent-skills)
  • SKILL.md 15.1 KB
    ---
    name: wp-admin-postbox-sortable
    description: 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.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "wordpress"
      wp-skills-plugin-version-tested: "6.0 - 7.1"
      wp-skills-wp-version-tested: "7.1"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-08-20"
    ---
    
    # 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.
    
    | Piece | Owner | Failure mode if missing |
    |---|---|---|
    | 1. `postbox` script enqueued | Your `admin_enqueue_scripts` callback | No collapse, no drag |
    | 2. Correct DOM (`.meta-box-sortables` > `.postbox` > `.hndle`) | Your view template | Sortable doesn't init, collapse classes don't bind |
    | 3. The two nonce fields in the form | Your view template | AJAX persistence fails; state is not stored |
    | 4. `postboxes.add_postbox_toggles( pageId )` called on DOM ready | Your admin JS | No 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.
    
    ```php
    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.
    
    ```php
    <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".
    
    ```php
    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.
    
    ```js
    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.
    
    In WordPress 7.1, core-generated postboxes also receive a named `role="region"`,
    an ID on the `.hndle` heading, and `wp_get_tooltip()` wrappers for move/show-hide
    controls. The `postbox` handle depends on `wp-tooltip`. Prefer `do_meta_boxes()`
    so these semantics update with core. If you hand-copy postbox markup, you own
    the region name, heading/control association, tooltip assets, and future drift.
    
    ## Per-user persistence — the three storage keys
    
    Replace `$page` with the sanitize_key'd page id you passed to `add_postbox_toggles()`.
    
    | User meta key | Contents | Set by |
    |---|---|---|
    | `closedpostboxes_$page` | Array of postbox IDs that are currently collapsed | AJAX `closed-postboxes` action |
    | `metaboxhidden_$page` | Array of postbox IDs hidden via Screen Options | Same AJAX action; ALWAYS exempts `submitdiv`, `linksubmitdiv`, `manage-menu`, `create-menu` |
    | `meta-box-order_$page` | Map of `context => csv of postbox ids in order` | AJAX `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.
    
    ```js
    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.
    
    ```php
    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' ),
    ) );
    ```
    
    ```js
    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
    
    ```js
    // 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' );
    } );
    ```
    
    ```php
    // 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 );
    ```
    
    ```php
    // 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
    
    - `wp-admin/js/postbox.js` — the canonical client-side implementation; read it before guessing behavior.
    - `wp-admin/includes/ajax-actions.php` — `wp_ajax_closed_postboxes()` at line 1803 and `wp_ajax_meta_box_order()` at line 1988.
    - `wp-admin/includes/template.php` — `add_meta_box()` at line 1080, `do_meta_boxes()` at line 1304.
    - `wp-includes/script-loader.php:1439` — the `postbox` script registration with its dep array.
    - Official documentation: <https://developer.wordpress.org/reference/functions/add_meta_box/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/do_meta_boxes/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/wp_nonce_field/>
    - Official documentation: <https://api.jqueryui.com/sortable/>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related