@wordpress/block-library
Version:
Block library for the WordPress editor.
221 lines (213 loc) • 6.2 kB
JavaScript
/**
* WordPress dependencies
*/
import {
store,
getContext,
getElement,
withSyncEvent,
} from '@wordpress/interactivity';
const focusableSelectors = [
'a[href]',
'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
'select:not([disabled]):not([aria-hidden])',
'textarea:not([disabled]):not([aria-hidden])',
'button:not([disabled]):not([aria-hidden])',
'[contenteditable]',
'[tabindex]:not([tabindex^="-"])',
];
// This is a fix for Safari in iOS/iPadOS. Without it, Safari doesn't focus out
// when the user taps in the body. It can be removed once we add an overlay to
// capture the clicks, instead of relying on the focusout event.
document.addEventListener( 'click', () => {} );
const { state, actions } = store(
'core/navigation',
{
state: {
get roleAttribute() {
const ctx = getContext();
return ctx.type === 'overlay' && state.isMenuOpen
? 'dialog'
: null;
},
get ariaModal() {
const ctx = getContext();
return ctx.type === 'overlay' && state.isMenuOpen
? 'true'
: null;
},
get ariaLabel() {
const ctx = getContext();
return ctx.type === 'overlay' && state.isMenuOpen
? ctx.ariaLabel
: null;
},
get isMenuOpen() {
// The menu is opened if either `click`, `hover` or `focus` is true.
return (
Object.values( state.menuOpenedBy ).filter( Boolean )
.length > 0
);
},
get menuOpenedBy() {
const ctx = getContext();
return ctx.type === 'overlay'
? ctx.overlayOpenedBy
: ctx.submenuOpenedBy;
},
},
actions: {
openMenuOnHover() {
const { type, overlayOpenedBy } = getContext();
if (
type === 'submenu' &&
// Only open on hover if the overlay is closed.
Object.values( overlayOpenedBy || {} ).filter( Boolean )
.length === 0
) {
actions.openMenu( 'hover' );
}
},
closeMenuOnHover() {
const { type, overlayOpenedBy } = getContext();
if (
type === 'submenu' &&
// Only close on hover if the overlay is closed.
Object.values( overlayOpenedBy || {} ).filter( Boolean )
.length === 0
) {
actions.closeMenu( 'hover' );
}
},
openMenuOnClick() {
const ctx = getContext();
const { ref } = getElement();
ctx.previousFocus = ref;
actions.openMenu( 'click' );
},
closeMenuOnClick() {
actions.closeMenu( 'click' );
actions.closeMenu( 'focus' );
},
openMenuOnFocus() {
actions.openMenu( 'focus' );
},
toggleMenuOnClick() {
const ctx = getContext();
const { ref } = getElement();
// Safari won't send focus to the clicked element, so we need to manually place it: https://bugs.webkit.org/show_bug.cgi?id=22261
if ( window.document.activeElement !== ref ) {
ref.focus();
}
const { menuOpenedBy } = state;
if ( menuOpenedBy.click || menuOpenedBy.focus ) {
actions.closeMenu( 'click' );
actions.closeMenu( 'focus' );
} else {
ctx.previousFocus = ref;
actions.openMenu( 'click' );
}
},
handleMenuKeydown: withSyncEvent( ( event ) => {
const { type, firstFocusableElement, lastFocusableElement } =
getContext();
if ( state.menuOpenedBy.click ) {
// If Escape close the menu.
if ( event.key === 'Escape' ) {
event.stopPropagation(); // Keeps ancestor menus open.
actions.closeMenu( 'click' );
actions.closeMenu( 'focus' );
return;
}
// Trap focus if it is an overlay (main menu).
if ( type === 'overlay' && event.key === 'Tab' ) {
// If shift + tab it change the direction.
if (
event.shiftKey &&
window.document.activeElement ===
firstFocusableElement
) {
event.preventDefault();
lastFocusableElement.focus();
} else if (
! event.shiftKey &&
window.document.activeElement ===
lastFocusableElement
) {
event.preventDefault();
firstFocusableElement.focus();
}
}
}
} ),
handleMenuFocusout( event ) {
const { modal, type } = getContext();
// If focus is outside modal, and in the document, close menu
// event.target === The element losing focus
// event.relatedTarget === The element receiving focus (if any)
// When focusout is outside the document,
// `window.document.activeElement` doesn't change.
// The event.relatedTarget is null when something outside the navigation menu is clicked. This is only necessary for Safari.
if (
event.relatedTarget === null ||
( ! modal?.contains( event.relatedTarget ) &&
event.target !== window.document.activeElement &&
type === 'submenu' )
) {
actions.closeMenu( 'click' );
actions.closeMenu( 'focus' );
}
},
openMenu( menuOpenedOn = 'click' ) {
const { type } = getContext();
state.menuOpenedBy[ menuOpenedOn ] = true;
if ( type === 'overlay' ) {
// Add a `has-modal-open` class to the <html> root.
document.documentElement.classList.add( 'has-modal-open' );
}
},
closeMenu( menuClosedOn = 'click' ) {
const ctx = getContext();
state.menuOpenedBy[ menuClosedOn ] = false;
// Check if the menu is still open or not.
if ( ! state.isMenuOpen ) {
if (
ctx.modal?.contains( window.document.activeElement )
) {
ctx.previousFocus?.focus();
}
ctx.modal = null;
ctx.previousFocus = null;
if ( ctx.type === 'overlay' ) {
document.documentElement.classList.remove(
'has-modal-open'
);
}
}
},
},
callbacks: {
initMenu() {
const ctx = getContext();
const { ref } = getElement();
if ( state.isMenuOpen ) {
const focusableElements =
ref.querySelectorAll( focusableSelectors );
ctx.modal = ref;
ctx.firstFocusableElement = focusableElements[ 0 ];
ctx.lastFocusableElement =
focusableElements[ focusableElements.length - 1 ];
}
},
focusFirstElement() {
const { ref } = getElement();
if ( state.isMenuOpen ) {
const focusableElements =
ref.querySelectorAll( focusableSelectors );
focusableElements?.[ 0 ]?.focus();
}
},
},
},
{ lock: true }
);