UNPKG

@atlaskit/editor-common

Version:

A package that contains common classes and components for editor and renderer

155 lines (149 loc) 6.86 kB
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); }