@atlaskit/editor-common
Version:
A package that contains common classes and components for editor and renderer
155 lines (149 loc) • 6.86 kB
JavaScript
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
const hasEnabledItems = list => list.some(item => item.getAttribute('aria-disabled') !== 'true');
/**
* This component is a wrapper of vertical menus which listens to keydown events of children
* and handles up/down arrow key navigation
*/
export const MenuArrowKeyNavigationProvider = ({
children,
handleClose,
disableArrowKeyNavigation,
keyDownHandlerContext,
closeOnTab,
onSelection,
editorRef
}) => {
const wrapperRef = useRef(null);
const [currentSelectedItemIndex, setCurrentSelectedItemIndex] = useState(-1);
const [listenerTargetElement] = useState(editorRef.current);
const incrementIndex = useCallback(list => {
const currentIndex = currentSelectedItemIndex;
let nextIndex = (currentIndex + 1) % list.length;
// Skips disabled items. Previously this function relied on a list of enabled elements which caused a
// difference between currentIndex and the item index in the menu.
while (nextIndex !== currentIndex && list[nextIndex].getAttribute('aria-disabled') === 'true') {
nextIndex = (nextIndex + 1) % list.length;
}
setCurrentSelectedItemIndex(nextIndex);
return nextIndex;
}, [currentSelectedItemIndex]);
const decrementIndex = useCallback(list => {
const currentIndex = currentSelectedItemIndex;
let nextIndex = (list.length + currentIndex - 1) % list.length;
while (nextIndex !== currentIndex && list[nextIndex].getAttribute('aria-disabled') === 'true') {
nextIndex = (list.length + nextIndex - 1) % list.length;
}
setCurrentSelectedItemIndex(nextIndex);
return nextIndex;
}, [currentSelectedItemIndex]);
// this useEffect uses onSelection in it's dependency list which gets
// changed as a result of the dropdown menu getting re-rendered in it's
// parent component. Note that if onSelection gets updated to useMemo
// this will no longer work.
useEffect(() => {
const currentIndex = currentSelectedItemIndex;
const list = getFocusableElements(wrapperRef === null || wrapperRef === void 0 ? void 0 : wrapperRef.current);
const currentElement = list[currentIndex];
if (currentElement && currentElement.getAttribute('aria-disabled') === 'true') {
var _list$focusIndex;
const focusIndex = incrementIndex(list);
(_list$focusIndex = list[focusIndex]) === null || _list$focusIndex === void 0 ? void 0 : _list$focusIndex.focus();
}
}, [currentSelectedItemIndex, onSelection, incrementIndex, decrementIndex]);
useLayoutEffect(() => {
if (disableArrowKeyNavigation) {
return;
}
/**
* To handle the key events on the list
* @param event
*/
const handleKeyDown = event => {
var _wrapperRef$current;
const targetElement = event.target;
// Tab key on menu items can be handled in the parent components of dropdown menus with KeydownHandlerContext
if (event.key === 'Tab' && closeOnTab) {
handleClose(event);
keyDownHandlerContext === null || keyDownHandlerContext === void 0 ? void 0 : keyDownHandlerContext.handleTab();
return;
}
// To trap the focus inside the toolbar using left and right arrow keys
const focusableElements = getFocusableElements(wrapperRef === null || wrapperRef === void 0 ? void 0 : wrapperRef.current);
if (!focusableElements || (focusableElements === null || focusableElements === void 0 ? void 0 : focusableElements.length) === 0) {
return;
}
if (!((_wrapperRef$current = wrapperRef.current) !== null && _wrapperRef$current !== void 0 && _wrapperRef$current.contains(targetElement))) {
setCurrentSelectedItemIndex(-1);
}
switch (event.key) {
case 'ArrowDown':
{
if (hasEnabledItems(focusableElements)) {
var _focusableElements$fo;
const focusIndex = incrementIndex(focusableElements);
(_focusableElements$fo = focusableElements[focusIndex]) === null || _focusableElements$fo === void 0 ? void 0 : _focusableElements$fo.focus();
event.preventDefault();
}
break;
}
case 'ArrowUp':
{
if (hasEnabledItems(focusableElements)) {
var _focusableElements$fo2;
const focusIndex = decrementIndex(focusableElements);
(_focusableElements$fo2 = focusableElements[focusIndex]) === null || _focusableElements$fo2 === void 0 ? void 0 : _focusableElements$fo2.focus();
event.preventDefault();
}
break;
}
// ArrowLeft/Right on the menu should close the menus
// then logic to retain the focus can be handled in the parent components with KeydownHandlerContext
case 'ArrowLeft':
// Filter out the events from outside the menu
if (!targetElement.closest('.custom-key-handler-wrapper')) {
return;
}
handleClose(event);
if (!targetElement.closest('[data-testid="editor-floating-toolbar"]')) {
keyDownHandlerContext === null || keyDownHandlerContext === void 0 ? void 0 : keyDownHandlerContext.handleArrowLeft();
}
break;
case 'ArrowRight':
// Filter out the events from outside the menu
if (!targetElement.closest('.custom-key-handler-wrapper')) {
return;
}
handleClose(event);
if (!targetElement.closest('[data-testid="editor-floating-toolbar"]')) {
keyDownHandlerContext === null || keyDownHandlerContext === void 0 ? void 0 : keyDownHandlerContext.handleArrowRight();
}
break;
case 'Escape':
handleClose(event);
break;
case 'Enter':
if (typeof onSelection === 'function') {
onSelection(currentSelectedItemIndex);
}
break;
default:
return;
}
};
listenerTargetElement && listenerTargetElement.addEventListener('keydown', handleKeyDown);
return () => {
listenerTargetElement && listenerTargetElement.removeEventListener('keydown', handleKeyDown);
};
}, [currentSelectedItemIndex, wrapperRef, handleClose, disableArrowKeyNavigation, keyDownHandlerContext, closeOnTab, onSelection, incrementIndex, decrementIndex, listenerTargetElement]);
return /*#__PURE__*/React.createElement("div", {
className: "menu-key-handler-wrapper custom-key-handler-wrapper",
ref: wrapperRef
}, children);
};
function getFocusableElements(rootNode) {
if (!rootNode) {
return [];
}
const focusableModalElements = rootNode.querySelectorAll('a[href], button:not([disabled]), textarea, input, select, div[tabindex="-1"]') || [];
return Array.from(focusableModalElements);
}