Files
drupal11-ddev/web/core/modules/navigation/js/arrow-navigation.js
2025-10-08 11:39:17 -04:00

266 lines
8.6 KiB
JavaScript

/**
* @file
* Keyboard navigation component.
*/
((Drupal, once, { focusable }) => {
/**
* Attaches the keyboard navigation functionality.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the behavior to the `.admin-toolbar` element.
*/
Drupal.behaviors.keyboardNavigation = {
attach: (context) => {
once('keyboard-processed', '.admin-toolbar', context).forEach(
(sidebar) => {
const IS_RTL = document.documentElement.dir === 'rtl';
const isInteractive = (element) =>
element.getAttribute('aria-expanded');
const getFocusableGroup = (element) =>
element.closest('[class*="toolbar-menu--level-"]') ||
element.closest('[data-toolbar-popover-wrapper]') ||
element.closest('.admin-toolbar');
const findFirstElementByChar = (focusableElements, targetChar) => {
const elementWIthChar = Array.prototype.find.call(
focusableElements,
(element) => {
const dataText = element.dataset.indexText;
return dataText && dataText[0] === targetChar;
},
);
return elementWIthChar;
};
const checkChar = ({ key, target }) => {
const currentGroup = getFocusableGroup(target);
const foundElementWithIndexChar = findFirstElementByChar(
focusable(currentGroup),
key,
);
if (foundElementWithIndexChar) {
foundElementWithIndexChar.focus();
}
};
const focusFirstInGroup = (focusableElements) => {
focusableElements[0].focus();
};
const focusLastInGroup = (focusableElements) => {
focusableElements[focusableElements.length - 1].focus();
};
const focusNextInGroup = (focusableElements, element) => {
const currentIndex = Array.prototype.indexOf.call(
focusableElements,
element,
);
if (currentIndex === focusableElements.length - 1) {
focusableElements[0].focus();
} else {
focusableElements[currentIndex + 1].focus();
}
};
const focusPreviousInGroup = (focusableElements, element) => {
const currentIndex = Array.prototype.indexOf.call(
focusableElements,
element,
);
if (currentIndex === 0) {
focusableElements[focusableElements.length - 1].focus();
} else {
focusableElements[currentIndex - 1].focus();
}
};
const toggleMenu = (element, state) =>
element.dispatchEvent(
new CustomEvent('toolbar-menu-set-toggle', {
bubbles: false,
detail: {
state,
},
}),
);
const closePopover = (element) =>
element.dispatchEvent(
new CustomEvent('toolbar-popover-close', { bubbles: true }),
);
const openPopover = (element) =>
element.dispatchEvent(
new CustomEvent('toolbar-popover-open', { bubbles: true }),
);
const focusClosestPopoverTrigger = (element) => {
element
.closest('[data-toolbar-popover]')
?.querySelector('[data-toolbar-popover-control]')
?.focus();
};
const focusFirstMenuElement = (element) => {
const elements = focusable(
element
.closest('.toolbar-menu__item')
?.querySelector('.toolbar-menu'),
);
if (elements?.length) {
elements[0].focus();
}
};
const focusFirstPopoverElement = (element) => {
// Zero is always popover trigger.
// And Popover header can be not interactive.
const elements = focusable(
element.closest('[data-toolbar-popover]'),
);
if (elements?.length >= 1) {
elements[1].focus();
}
};
const focusLastPopoverElement = (element) => {
const elements = focusable(
element.closest('[data-toolbar-popover]'),
);
if (elements?.length > 0) {
elements[elements.length - 1].focus();
}
};
const closeNonInteractiveElement = (element) => {
// If we are inside submenus.
if (element.closest('[class*="toolbar-menu--level-"]')) {
const trigger =
element.closest('.toolbar-menu')?.previousElementSibling;
toggleMenu(trigger, false);
trigger.focus();
} else {
closePopover(element);
focusClosestPopoverTrigger(element);
}
};
const openInteractiveElement = (element) => {
// If menu button.
if (element.hasAttribute('data-toolbar-menu-trigger')) {
toggleMenu(element, true);
focusFirstMenuElement(element);
}
// If popover trigger.
if (element.hasAttribute('data-toolbar-popover-control')) {
openPopover(element);
focusFirstPopoverElement(element);
}
};
const closeInteractiveElement = (element) => {
// If menu button.
if (element.hasAttribute('data-toolbar-menu-trigger')) {
if (element.getAttribute('aria-expanded') === 'false') {
closeNonInteractiveElement(element);
} else {
toggleMenu(element, false);
focusFirstMenuElement(element);
}
}
// If popover trigger.
if (element.hasAttribute('data-toolbar-popover-control')) {
openPopover(element);
focusLastPopoverElement(element);
}
};
const arrowsSideControl = ({ key, target }) => {
if (
(key === 'ArrowRight' && !IS_RTL) ||
(key === 'ArrowLeft' && IS_RTL)
) {
if (isInteractive(target)) {
openInteractiveElement(target);
// If also we want to care about expand button.
if (
target.getAttribute('aria-controls') === 'admin-toolbar' &&
target.getAttribute('aria-expanded') === 'false'
) {
target.click();
}
}
} else if (
(key === 'ArrowRight' && IS_RTL) ||
(key === 'ArrowLeft' && !IS_RTL)
) {
if (isInteractive(target)) {
closeInteractiveElement(target);
// If also we want to care about expand button.
if (
target.getAttribute('aria-controls') === 'admin-toolbar' &&
target.getAttribute('aria-expanded') !== 'false'
) {
target.click();
}
} else {
closeNonInteractiveElement(target);
}
}
};
const arrowsDirectionControl = ({ key, target }) => {
const focusableElements = focusable(getFocusableGroup(target));
if (key === 'ArrowUp') {
focusPreviousInGroup(focusableElements, target);
} else if (key === 'ArrowDown') {
focusNextInGroup(focusableElements, target);
}
};
sidebar.addEventListener('keydown', (e) => {
switch (e.key) {
case 'Escape':
closePopover(e.target);
focusClosestPopoverTrigger(e.target);
break;
case 'ArrowLeft':
case 'ArrowRight':
e.preventDefault();
arrowsSideControl(e);
break;
case 'ArrowDown':
case 'ArrowUp':
e.preventDefault();
arrowsDirectionControl(e);
break;
case 'Home':
e.preventDefault();
focusFirstInGroup(getFocusableGroup(e.target));
break;
case 'End':
e.preventDefault();
focusLastInGroup(getFocusableGroup(e.target));
break;
default:
checkChar(e);
break;
}
});
},
);
},
};
})(Drupal, once, window.tabbable);