Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/block-library/src/navigation/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -1146,8 +1146,8 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut
$open_on_hover = 'hover' === $computed_visibility;

if ( $open_on_hover ) {
$tags->set_attribute( 'data-wp-on--mouseenter', 'actions.openMenuOnHover' );
$tags->set_attribute( 'data-wp-on--mouseleave', 'actions.closeMenuOnHover' );
$tags->set_attribute( 'data-wp-on--pointerenter', 'actions.openMenuOnHover' );
$tags->set_attribute( 'data-wp-on--pointerleave', 'actions.closeMenuOnHover' );
}

// Add directives to the toggle submenu button.
Expand Down
8 changes: 7 additions & 1 deletion packages/block-library/src/navigation/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,13 @@ $navigation-icon-size: 24px;
}

// Show submenus on hover unless they open on click.
&:not(.open-on-click):hover > .wp-block-navigation__submenu-container,
// Restricted to true-hover devices to prevent sticky touch-hover on mobile.
@media (hover: hover) {
&:not(.open-on-click):hover > .wp-block-navigation__submenu-container {
@include show-submenu;
min-width: 200px;
}
}
// Keep submenus open when focus is within.
&:not(.open-on-click):not(.open-on-hover-click):focus-within > .wp-block-navigation__submenu-container,
// Show submenus on click.
Expand Down
16 changes: 14 additions & 2 deletions packages/block-library/src/navigation/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,12 @@ const { state, actions } = store(
},
},
actions: {
openMenuOnHover() {
openMenuOnHover( event ) {
// Pointer events from touch should not open the submenu on hover;
// touch devices toggle via the click action instead.
if ( event?.pointerType === 'touch' ) {
return;
}
const { type, overlayOpenedBy } = getContext();
if (
type === 'submenu' &&
Expand All @@ -93,7 +98,10 @@ const { state, actions } = store(
actions.openMenu( 'hover' );
}
},
closeMenuOnHover() {
closeMenuOnHover( event ) {
if ( event?.pointerType === 'touch' ) {
return;
}
const { type, overlayOpenedBy } = getContext();
if (
type === 'submenu' &&
Expand Down Expand Up @@ -128,6 +136,10 @@ const { state, actions } = store(
if ( menuOpenedBy.click || menuOpenedBy.focus ) {
actions.closeMenu( 'click' );
actions.closeMenu( 'focus' );
// Also clear hover in case it was set by a synthetic pointerenter
// on touch (e.g. the browser-fired mouseenter-equivalent before
// the click event), ensuring the submenu fully closes.
actions.closeMenu( 'hover' );
} else {
ctx.previousFocus = ref;
actions.openMenu( 'click' );
Expand Down
136 changes: 136 additions & 0 deletions test/e2e/specs/editor/blocks/navigation-frontend-interactivity.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -629,5 +629,141 @@ test.describe( 'Navigation block - Frontend interactivity', () => {
await expect( submenuItem ).toHaveClass( /open-on-click/ );
} );
} );

test.describe( 'Submenu touch device interactions', () => {
test.beforeEach( async ( { admin, editor, requestUtils } ) => {
await admin.visitSiteEditor( {
postId: 'emptytheme//header',
postType: 'wp_template_part',
canvas: 'edit',
} );
await requestUtils.createNavigationMenu( {
title: 'Touch test menu',
content: `
<!-- wp:navigation-submenu {"label":"Submenu","type":"internal","url":"#heading","kind":"custom"} -->
<!-- wp:navigation-link {"label":"Submenu Link","type":"custom","url":"http://www.wordpress.org/"} /-->
<!-- /wp:navigation-submenu -->
`,
} );
await editor.insertBlock( {
name: 'core/navigation',
attributes: { overlayMenu: 'off' },
} );
await editor.saveSiteEditorEntities( {
isOnlyCurrentEntityDirty: true,
} );
} );

test( 'submenu does not open via hover on touch devices', async ( {
page,
browser,
} ) => {
// Create a touch device context where (hover: none) matches.
const touchContext = await browser.newContext( {
hasTouch: true,
} );
const touchPage = await touchContext.newPage();

// Copy auth cookies from the original context.
const cookies = await page.context().cookies();
await touchContext.addCookies( cookies );

await touchPage.goto( new URL( '/', page.url() ).href );

const innerElement = touchPage.getByRole( 'link', {
name: 'Submenu Link',
} );

// Submenu should be hidden initially.
await expect( innerElement ).toBeHidden();

// Simulate a touch pointerenter event. On real touch devices,
// tapping an element fires pointerenter with pointerType "touch"
// before the click event, which would previously set hover=true
// and leave the submenu stuck open. Our guard should return early
// and leave the submenu hidden.
const submenuLi = touchPage.locator( 'li.has-child' ).first();
await submenuLi.dispatchEvent( 'pointerenter', {
pointerType: 'touch',
} );
await expect( innerElement ).toBeHidden();

await touchContext.close();
} );

test( 'chevron opens and closes submenu on touch devices', async ( {
page,
browser,
} ) => {
// Create a touch device context where (hover: none) matches.
const touchContext = await browser.newContext( {
hasTouch: true,
} );
const touchPage = await touchContext.newPage();

// Copy auth cookies from the original context.
const cookies = await page.context().cookies();
await touchContext.addCookies( cookies );

await touchPage.goto( new URL( '/', page.url() ).href );

const arrowButton = touchPage.getByRole( 'button', {
name: 'Submenu submenu',
} );
const innerElement = touchPage.getByRole( 'link', {
name: 'Submenu Link',
} );

// Submenu should be hidden initially.
await expect( innerElement ).toBeHidden();

// Click the chevron to open the submenu.
await arrowButton.click();
await expect( arrowButton ).toHaveAttribute(
'aria-expanded',
'true'
);
await expect( innerElement ).toBeVisible();

// Click the chevron again to close the submenu.
await arrowButton.click();
await expect( arrowButton ).toHaveAttribute(
'aria-expanded',
'false'
);

// The submenu may still be visible due to CSS :focus-within
// while the button retains focus. Clicking elsewhere removes
// focus and the submenu should then be hidden.
await touchPage
.locator( 'body' )
.click( { position: { x: 0, y: 0 } } );
await expect( innerElement ).toBeHidden();

await touchContext.close();
} );

test( 'submenu still opens via hover on non-touch devices', async ( {
page,
} ) => {
await page.goto( '/' );

const innerElement = page.getByRole( 'link', {
name: 'Submenu Link',
} );

// Submenu should be hidden initially.
await expect( innerElement ).toBeHidden();

// On a non-touch device (default Playwright context),
// pointerenter with pointerType "mouse" should still open the
// submenu via hover — verifying we haven't broken desktop hover.
const submenuLi = page.locator( 'li.has-child' ).first();
await submenuLi.dispatchEvent( 'pointerenter', {
pointerType: 'mouse',
} );
await expect( innerElement ).toBeVisible();
} );
} );
} );
} );
Loading