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.