Wordpress - WordPress taxonomy radio buttons

However, this will turn ALL terms checkboxes to radio buttons.

Not only that, it'll turn any checkbox in a meta box - not ideal!

Instead, let's specifically target the wp_terms_checklist() function, which is used to generate the list of checkboxes across the admin (including quick edit).

/**
 * Use radio inputs instead of checkboxes for term checklists in specified taxonomies.
 *
 * @param   array   $args
 * @return  array
 */
function wpse_139269_term_radio_checklist( $args ) {
    if ( ! empty( $args['taxonomy'] ) && $args['taxonomy'] === 'category' /* <== Change to your required taxonomy */ ) {
        if ( empty( $args['walker'] ) || is_a( $args['walker'], 'Walker' ) ) { // Don't override 3rd party walkers.
            if ( ! class_exists( 'WPSE_139269_Walker_Category_Radio_Checklist' ) ) {
                /**
                 * Custom walker for switching checkbox inputs to radio.
                 *
                 * @see Walker_Category_Checklist
                 */
                class WPSE_139269_Walker_Category_Radio_Checklist extends Walker_Category_Checklist {
                    function walk( $elements, $max_depth, ...$args ) {
                        $output = parent::walk( $elements, $max_depth, ...$args );
                        $output = str_replace(
                            array( 'type="checkbox"', "type='checkbox'" ),
                            array( 'type="radio"', "type='radio'" ),
                            $output
                        );

                        return $output;
                    }
                }
            }

            $args['walker'] = new WPSE_139269_Walker_Category_Radio_Checklist;
        }
    }

    return $args;
}

add_filter( 'wp_terms_checklist_args', 'wpse_139269_term_radio_checklist' );

We hook onto the wp_terms_checklist_args filter, then implement our own custom "walker" (a family of classes used to generate hierarchical lists). From there, it's a simply string replace of type="checkbox" with type="radio" if the taxonomy is whatever we've configured it to match (in this case "category").


The following does pretty much what @TheDeadMedic did at his answer good answer, brought me there half the way, so this is kind of just an addition to it. Out of personal preference I opted to do it with start_el.

→ make sure to replace YOUR-TAXONOMY in below code according to your needs

add_filter( 'wp_terms_checklist_args', 'wpse_139269_term_radio_checklist_start_el_version', 10, 2 );
function wpse_139269_term_radio_checklist_start_el_version( $args, $post_id ) {
    if ( ! empty( $args['taxonomy'] ) && $args['taxonomy'] === 'YOUR-TAXONOMY' ) {
        if ( empty( $args['walker'] ) || is_a( $args['walker'], 'Walker' ) ) { // Don't override 3rd party walkers.
            if ( ! class_exists( 'WPSE_139269_Walker_Category_Radio_Checklist_Start_El_Version' ) ) {
                class WPSE_139269_Walker_Category_Radio_Checklist_Start_El_Version extends Walker_Category_Checklist {
                    public function start_el( &$output, $category, $depth = 0, $args = array(), $id = 0 ) {
                        if ( empty( $args['taxonomy'] ) ) {
                            $taxonomy = 'category';
                        } else {
                            $taxonomy = $args['taxonomy'];
                        }

                        if ( $taxonomy == 'category' ) {
                            $name = 'post_category';
                        } else {
                            $name = 'tax_input[' . $taxonomy . ']';
                        }

                        $args['popular_cats'] = empty( $args['popular_cats'] ) ? array() : $args['popular_cats'];
                        $class = in_array( $category->term_id, $args['popular_cats'] ) ? ' class="popular-category"' : '';

                        $args['selected_cats'] = empty( $args['selected_cats'] ) ? array() : $args['selected_cats'];

                        /** This filter is documented in wp-includes/category-template.php */
                        if ( ! empty( $args['list_only'] ) ) {
                            $aria_cheched = 'false';
                            $inner_class = 'category';

                            if ( in_array( $category->term_id, $args['selected_cats'] ) ) {
                                $inner_class .= ' selected';
                                $aria_cheched = 'true';
                            }

                            $output .= "\n" . '<li' . $class . '>' .
                                '<div class="' . $inner_class . '" data-term-id=' . $category->term_id .
                                ' tabindex="0" role="checkbox" aria-checked="' . $aria_cheched . '">' .
                                esc_html( apply_filters( 'the_category', $category->name ) ) . '</div>';
                        } else {
                            $output .= "\n<li id='{$taxonomy}-{$category->term_id}'$class>" .
                            '<label class="selectit"><input value="' . $category->term_id . '" type="radio" name="'.$name.'[]" id="in-'.$taxonomy.'-' . $category->term_id . '"' .
                            checked( in_array( $category->term_id, $args['selected_cats'] ), true, false ) .
                            disabled( empty( $args['disabled'] ), false, false ) . ' /> ' .
                            esc_html( apply_filters( 'the_category', $category->name ) ) . '</label>';
                        }
                    }
                }
            }
            $args['walker'] = new WPSE_139269_Walker_Category_Radio_Checklist_Start_El_Version;
        }
    }
    return $args;
}

Now as @ Howdy_McGee correctly stated in his comment this doesn't work nicely, correctly with the quick/inline edit. The above code handles the saving correctly, but the radio at the inline edit isn't checked. Of course we want that, for this I have done this:

→ write some JQuery code to handle the checked state
→ file name: editphp-inline-edit-tax-radio-hack.js - used below for enqueueing

jQuery(document).ready(function($) {
    var taxonomy = 'status',
        post_id = null,
        term_id = null,
        li_ele_id = null;
    $('a.editinline').on('click', function() {
        post_id = inlineEditPost.getId(this);
        $.ajax({
            url: ajaxurl,
            data: {
                action: 'wpse_139269_inline_edit_radio_checked_hack',
                'ajax-taxonomy': taxonomy,
                'ajax-post-id': post_id
            },
            type: 'POST',
            dataType: 'json',
            success: function (response) {
                term_id = response;
                li_ele_id = 'in-' + taxonomy + '-' + term_id;
                $( 'input[id="'+li_ele_id+'"]' ).attr( 'checked', 'checked' );
            }
        });
    });

});

→ we need an AJAX action - as seen in above code block

add_action( 'wp_ajax_wpse_139269_inline_edit_radio_checked_hack', 'wpse_139269_inline_edit_radio_checked_hack' );
add_action( 'wp_ajax_nopriv_wpse_139269_inline_edit_radio_checked_hack', 'wpse_139269_inline_edit_radio_checked_hack' );
function wpse_139269_inline_edit_radio_checked_hack() {
    $terms = wp_get_object_terms(
        $_POST[ 'ajax-post-id' ],
        $_POST[ 'ajax-taxonomy' ],
        array( 'fields' => 'ids' )
    );
    $result = $terms[ 0 ];
    echo json_encode($result);
    exit;
    die();
}

→ enqueueing above script
→ change the path information according to your needs

add_action( 'admin_enqueue_scripts', 'wpse_139269_inline_edit_radio_checked_hack_enqueue_script' );
function wpse_139269_inline_edit_radio_checked_hack_enqueue_script() {
    wp_enqueue_script(
        'editphp-inline-edit-tax-radio-hack-js',
        get_template_directory_uri() . '/your/path/editphp-inline-edit-tax-radio-hack.js',
        array( 'jquery' )
    );
}

This is working quite nicely so far, but only for the first time, when opening the inline edit a second time we have lost the checked state again. We obviously don't want that. To get around it I used a method I've found here by @brasofilo. What it does is reloading the updated inline edit section. This leads to the radio checkbox being correctly shown, no matter how often it is changed.

→ make sure to replace YOUR-POST-TYPE in below code according to your needs

add_action( 'wp_ajax_inline-save', 'wpse_139269_wp_ajax_inline_save', 0 );
function wpse_139269_wp_ajax_inline_save() {
    global $wp_list_table;

    check_ajax_referer( 'inlineeditnonce', '_inline_edit' );

    if ( ! isset($_POST['post_ID']) || ! ( $post_ID = (int) $_POST['post_ID'] ) )
        wp_die();

        if ( 'page' == $_POST['post_type'] ) {
            if ( ! current_user_can( 'edit_page', $post_ID ) )
                wp_die( __( 'You are not allowed to edit this page.' ) );
        } else {
            if ( ! current_user_can( 'edit_post', $post_ID ) )
                wp_die( __( 'You are not allowed to edit this post.' ) );
        }

        if ( $last = wp_check_post_lock( $post_ID ) ) {
            $last_user = get_userdata( $last );
            $last_user_name = $last_user ? $last_user->display_name : __( 'Someone' );
            printf( $_POST['post_type'] == 'page' ? __( 'Saving is disabled: %s is currently editing this page.' ) : __( 'Saving is disabled: %s is currently editing this post.' ),    esc_html( $last_user_name ) );
            wp_die();
        }

        $data = &$_POST;

        $post = get_post( $post_ID, ARRAY_A );

        // Since it's coming from the database.
        $post = wp_slash($post);

        $data['content'] = $post['post_content'];
        $data['excerpt'] = $post['post_excerpt'];

        // Rename.
        $data['user_ID'] = get_current_user_id();

        if ( isset($data['post_parent']) )
            $data['parent_id'] = $data['post_parent'];

            // Status.
            if ( isset($data['keep_private']) && 'private' == $data['keep_private'] )
                $data['post_status'] = 'private';
            else
                $data['post_status'] = $data['_status'];

            if ( empty($data['comment_status']) )
                $data['comment_status'] = 'closed';
            if ( empty($data['ping_status']) )
                $data['ping_status'] = 'closed';

            // Exclude terms from taxonomies that are not supposed to appear in Quick Edit.
            if ( ! empty( $data['tax_input'] ) ) {
                foreach ( $data['tax_input'] as $taxonomy => $terms ) {
                    $tax_object = get_taxonomy( $taxonomy );
                    /** This filter is documented in wp-admin/includes/class-wp-posts-list-table.php */
                    if ( ! apply_filters( 'quick_edit_show_taxonomy', $tax_object->show_in_quick_edit, $taxonomy, $post['post_type'] ) ) {
                        unset( $data['tax_input'][ $taxonomy ] );
                    }
                }
            }

            // Hack: wp_unique_post_slug() doesn't work for drafts, so we will fake that our post is published.
            if ( ! empty( $data['post_name'] ) && in_array( $post['post_status'], array( 'draft', 'pending' ) ) ) {
                $post['post_status'] = 'publish';
                $data['post_name'] = wp_unique_post_slug( $data['post_name'], $post['ID'], $post['post_status'], $post['post_type'], $post['post_parent'] );
            }

            // Update the post.
            edit_post();

            $wp_list_table = _get_list_table( 'WP_Posts_List_Table', array( 'screen' => $_POST['screen'] ) );

            $level = 0;
            $request_post = array( get_post( $_POST['post_ID'] ) );
            $parent = $request_post[0]->post_parent;

            while ( $parent > 0 ) {
                $parent_post = get_post( $parent );
                $parent = $parent_post->post_parent;
                $level++;
            }

            $wp_list_table->display_rows( array( get_post( $_POST['post_ID'] ) ), $level );

            if( $_POST['post_type'] == 'YOUR-POST-TYPE' ) {
                ?>
                    <script type="text/javascript">
                        document.location.reload(true);
                    </script>
                <?php
            }

    wp_die();
}



Note: Not extensively tested, but so far working well