Fixing the problem in this WordPress theme


The Twenty Twenty-One is a nice-looking theme with a “minimal” appearance, code and size for the minimalists out there (IMO). However, the JavaScript code in this theme has a small issue.

Background

According to MDN, there is a specific order for the browser to trigger events when there exist touch inputs:

touchstart => (touchmove) => touchend => mousemove => mousedown => mouseup => click

Generally, touch events are triggered before mouse events. Also, the browser could trigger both touch events and mouse events in response to the same user input.

What’s more, the browser must emulate mouse events when the listener is for mouse events, but the user input is touch.

Problem

Introduction

After testing the Twenty Twenty-One theme, I found that the primary menu located on the top-right of the webpage has two mouse event listeners for the drop-down menu in primary-navigation.js.

li.addEventListener( 'mouseenter', function() {
    this.querySelector( '.sub-menu-toggle' ).setAttribute( 'aria-expanded', 'true' );
    twentytwentyoneSubmenuPosition( li );
} );
li.addEventListener( 'mouseleave', function() {
    this.querySelector( '.sub-menu-toggle' ).setAttribute( 'aria-expanded', 'false' );
} );

This block of code tells the browser when the cursor enters (mouseenter) the drop-down menu area, which is a li element, open the drop-down menu and close the drop-down menu when the cursor left the area (mouseleave)

At the same time, the li element has a sub-element that is a button (shown as a plus/minus sign), which has a listener (click). When the cursor click on the button, the state of the drop-down menu will be toggled (open => close, close => open)

<button class="sub-menu-toggle" aria-expanded="false" onclick="twentytwentyoneExpandSubMenu(this)"></button>
function twentytwentyoneExpandSubMenu( el ) { // jshint ignore:line
	// Close other expanded items.
	el.closest( 'nav' ).querySelectorAll( '.sub-menu-toggle' ).forEach( function( button ) {
		if ( button !== el ) {
			button.setAttribute( 'aria-expanded', 'false' );
		}
	} );

	// Toggle aria-expanded on the button.
	twentytwentyoneToggleAriaExpanded( el, true );

	// On tab-away collapse the menu.
	el.parentNode.querySelectorAll( 'ul > li:last-child > a' ).forEach( function( linkEl ) {
		linkEl.addEventListener( 'blur', function( event ) {
			if ( ! el.parentNode.contains( event.relatedTarget ) ) {
				el.setAttribute( 'aria-expanded', 'false' );
			}
		} );
	} );
}

What happened

When I tested this theme on Chrome with touch input, I found that the drop-down menu would not open when I touched the drop-down menu button for the first time. Then the second time, the drop-down menu would open.

After that, it worked just fine when I touched the button to close the drop-down menu then again to open the menu, i. However, When I touched outside of the li element area, then touched the button again, the drop-down menu behaved like I touched the drop-down menu button for the first time, which is a problem.

Investigation

After some breakpoint testing, I found that when I touched the button for the first time, the mouseenter event (browser emulated) on the li element area was triggered, which opened the menu. Then since the button had a click listener, which toggled the menu, it closed the menu.

When I touched the button again, only click was fired since the browser emulated the mouseenter event and my touch input was still in the area, so no mouseleave event was fired. So no matter how many times I touched the button, as long as I did not touch outside of the li element area, everything would work just fine.

Then I touched outside of the li element area, the browser emulated a mouseleave event, which closed the menu regardless of the state of the menu. Based on the code I showed above, it was just the same thing as if I touched the button for the first time when I touch it again.

As someone who has some coding experience, I would definitely fix this problem. Otherwise, the visitor experience is not 100% satisfied.

Solution

Plan A: Partially succeeded

As I mentioned before and the documentation for MDN, I knew that I could add a touchend listener to the li element with preventDefault() to stop further handling of this touch input. Meanwhile, this would not interfere with mouse inputs.

li.addEventListener( "touchend", event => {
    event.preventDefault();
    let button = li.querySelector("button")
    button.click();
});

But since this input is stopped further handling, the button needs to be fired a click event to open/close the menu.

A note here, as some visitors may start scrolling on the li element, the browser will show this error in the console:

[Intervention] Ignored attempt to cancel a touchend event with cancelable=false, for example, because scrolling is in progress and cannot be interrupted.

A quick fix to this error is to add an if statement to detect if the event is cancelable.

li.addEventListener( "touchend", event => {
    if (event.cancelable) {
        event.preventDefault();
        let button = li.querySelector("button")
        button.click();
    }
});

However, a new problem showed up. The links (a elements) in the drop-down menu are sub-elements of the li element without any listeners. That means when I touch on the links, due to preventDefault(), the touch event will not pass to the a element, which causes the drop-down menu to close, and the link will not open.

Plan B: Succeeded

With some knowledge from JavaScript bubbling and capturing, the solution is to add a touchend listener to the a elements:

// stop bubbling to li
li.querySelector("ul").querySelectorAll("a").forEach(a => {
    a.addEventListener("touchend", event => {
        event.stopPropagation();
    });
});

stopPropagation() can prevent further propagation of the current event in the capturing and bubbling phases, which stops the event from being handled by the li element.

Funny Fact

The problem will not happen on Safari on iOS 15 (the only device I have to test on). The handling process may be different on Safari, I did not look into that.

Conclusion

Even though this post said that I fixed the problem with the code above, I added more code on JavaScript and CSS sides to fix the style.

By halyul

Never be a dreamer.

Leave a comment

Your email address will not be published. Required fields are marked *