Skip to content

Instantly share code, notes, and snippets.

@isuke01
Last active May 20, 2025 12:22
Show Gist options
  • Save isuke01/a60c3da5127b84f20b18cde0a8e4790e to your computer and use it in GitHub Desktop.
Save isuke01/a60c3da5127b84f20b18cde0a8e4790e to your computer and use it in GitHub Desktop.
Wordpress add extra checkbox to nav item to toggle to transform item into button. Button will act as an sub-menu toggle button, should cover aria-haspopup and aria-expanded
<?php
namespace SubmenuAccessibility;
/**
* Add a checkbox to the menu item settings to set it as a button.
*
* @param int $item_id The menu item ID.
* @param object $item The menu item object.
* @param int $depth The depth of the menu item.
* @param object $args The menu arguments.
*/
\add_action( 'wp_nav_menu_item_custom_fields', function ( $item_id, $item, $depth, $args ) {
$is_checked = get_post_meta( $item_id, '_menu_item_as_button', true ) ? 'checked' : '';
echo '<p class="field-toggle-button description description-wide">
<label for="edit-menu-item-as-button-' . \esc_attr( (string) $item_id ) . '">
<input type="checkbox" id="edit-menu-item-as-button-' . \esc_attr( (string) $item_id ) . '" name="menu-item-as-button[' . \esc_attr( (string) $item_id ) . ']" ' . $is_checked . ' />
' . \esc_html__( 'Use as a button', 'textdomain' ) . '</br>
' . \esc_html__( 'This is usefully as an submenu toggle (button)', 'textdomain' ) . '
</label>
</p>';
}, 10, 4 );
/**
* Save the checkbox value when the menu item is updated.
*
* @param int $menu_id The menu ID.
* @param int $menu_item_db_id The menu item database ID.
* @param object $args The menu arguments.
*/
\add_action( 'wp_update_nav_menu_item', function ( $menu_id, $menu_item_db_id, $args ) {
$value = isset( $_POST['menu-item-as-button'][ $menu_item_db_id ] ) ? '1' : '';
\update_post_meta( $menu_item_db_id, '_menu_item_as_button', $value );
}, 10, 3 );
\add_filter( 'wp_nav_menu_objects', function ( $items, $args ) {
foreach ( $items as &$item ) {
if ( get_post_meta( $item->ID, '_menu_item_as_button', true ) ) {
$item->is_button = true;
}
}
return $items;
}, 10, 2 );
\add_filter( 'walker_nav_menu_start_el', function ( $item_output, $item, $depth, $args ) {
if ( empty( $item->is_button ) ) {
return $item_output;
}
// Suppress HTML5 warnings.
libxml_use_internal_errors( true );
// Wrap with dummy HTML to avoid <html><body> boilerplate.
$html = '<div id="wrap">' . $item_output . '</div>';
$doc = new \DOMDocument();
$doc->loadHTML( mb_convert_encoding( $html, 'HTML-ENTITIES', 'UTF-8' ) );
$xpath = new \DOMXPath( $doc );
$a = $xpath->query( '//div[@id="wrap"]//a' )->item(0);
if ( ! $a ) {
return $item_output; // no <a> found.
}
// Create <button> element.
$button = $doc->createElement( 'button' );
$button->setAttribute( 'type', 'button' );
$button->setAttribute( 'data-ally11AV-js', 'true' );
$button->setAttribute( 'aria-haspopup', 'true' );
$button->setAttribute( 'aria-expanded', 'false' );
// Copy all attributes except those we want to remove.
foreach ( iterator_to_array( $a->attributes ) as $attr ) {
if ( in_array($attr->name, [ 'href', 'target', 'rel' ], true ) ) {
continue;
}
$button->setAttribute( $attr->name, $attr->value );
}
// Move inner HTML.
while ($a->firstChild) {
$button->appendChild( $a->firstChild );
}
// Replace <a> with <button>.
$a->parentNode->replaceChild( $button, $a );
// Extract only the updated content inside our wrapper.
$new_html = '';
foreach ( $doc->getElementById('wrap')->childNodes as $child ) {
$new_html .= $doc->saveHTML( $child );
}
return $new_html;
}, 10, 4 );
\add_action( 'wp_footer', function () {
?>
<script>
class AccessibleSubmenuToggle {
constructor(selector = '.menu-toggle-button') {
this.buttons = document.querySelectorAll(selector);
this.init();
}
init() {
this.buttons.forEach((button) => {
const parentLi = button.closest('li');
if (!parentLi || parentLi._submenuEventsBound) return;
parentLi._submenuEventsBound = true;
const submenu = parentLi.querySelector('.sub-menu');
// Click to toggle
button.addEventListener('click', () => {
const expanded = button.getAttribute('aria-expanded') === 'true';
if (expanded) {
this.close(button, parentLi);
} else {
this.open(button, parentLi);
}
});
// Mouse enter opens
parentLi.addEventListener('mouseenter', () => {
this.open(button, parentLi);
});
// Mouse leave closes
parentLi.addEventListener('mouseleave', () => {
this.close(button, parentLi);
});
// Focusout closes when focus leaves the parent <li>
parentLi.addEventListener('focusout', (e) => {
if (!parentLi.contains(e.relatedTarget)) {
this.close(button, parentLi);
}
});
});
}
open(button, parentLi) {
const submenu = parentLi.querySelector('.sub-menu');
button.setAttribute('aria-expanded', 'true');
parentLi.classList.add('active');
if (submenu) submenu.classList.add('open');
}
close(button, parentLi) {
const submenu = parentLi.querySelector('.sub-menu');
button.setAttribute('aria-expanded', 'false');
parentLi.classList.remove('active');
if (submenu) submenu.classList.remove('open');
}
}
new AccessibleSubmenuToggle('[data-ally11AV-js="true"]');
</script>
<?php
} );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment