UNPKG

@atlaskit/editor-common

Version:

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

270 lines (259 loc) • 13.7 kB
/** * @jsxRuntime classic * @jsx jsx */ import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; // eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled, @typescript-eslint/consistent-type-imports -- Ignored via go/DSP-18766; jsx required at runtime for @jsxRuntime classic import { css, jsx } from '@emotion/react'; import { ELEMENT_BROWSER_ID } from '../../element-browser'; import { fullPageMessages } from '../../messages'; import { mediaInsertMessages } from '../../messages/media-insert'; import { EDIT_AREA_ID } from '../../ui'; /* ** The context is used to handle the keydown events of submenus. ** Because the keyboard navigation is explicitly managed for main toolbar items ** Few key presses such as Tab,Arrow Right/Left need ot be handled here via context */ export const KeyDownHandlerContext = /*#__PURE__*/React.createContext({ handleArrowLeft: () => {}, handleArrowRight: () => {}, handleTab: () => {} }); const centeredToolbarContainer = css({ display: 'flex', width: '100%', alignItems: 'center' }); /** * This component is a wrapper of main toolbar which listens to keydown events of children * and handles left/right arrow key navigation for all focusable elements * @param * @returns */ export const ToolbarArrowKeyNavigationProvider = ({ children, editorView, childComponentSelector, handleEscape, disableArrowKeyNavigation, isShortcutToFocusToolbar, editorAppearance, useStickyToolbar, intl }) => { const wrapperRef = useRef(null); const selectedItemIndex = useRef(0); const incrementIndex = useCallback(list => { let index = 0; if (document.activeElement) { // Ignored via go/ees005 // eslint-disable-next-line @atlaskit/editor/no-as-casting index = list.indexOf(document.activeElement); index = (index + 1) % list.length; } selectedItemIndex.current = index; }, []); const decrementIndex = useCallback(list => { let index = 0; if (document.activeElement) { // Ignored via go/ees005 // eslint-disable-next-line @atlaskit/editor/no-as-casting index = list.indexOf(document.activeElement); index = (list.length + index - 1) % list.length; } selectedItemIndex.current = index; }, []); const handleArrowRightMemoized = useCallback(() => { var _filteredFocusableEle; const filteredFocusableElements = getFilteredFocusableElements(wrapperRef === null || wrapperRef === void 0 ? void 0 : wrapperRef.current); incrementIndex(filteredFocusableElements); (_filteredFocusableEle = filteredFocusableElements[selectedItemIndex.current]) === null || _filteredFocusableEle === void 0 ? void 0 : _filteredFocusableEle.focus(); }, [incrementIndex]); const handleArrowLeftMemoized = useCallback(() => { var _filteredFocusableEle2; const filteredFocusableElements = getFilteredFocusableElements(wrapperRef === null || wrapperRef === void 0 ? void 0 : wrapperRef.current); decrementIndex(filteredFocusableElements); (_filteredFocusableEle2 = filteredFocusableElements[selectedItemIndex.current]) === null || _filteredFocusableEle2 === void 0 ? void 0 : _filteredFocusableEle2.focus(); }, [decrementIndex]); const handleTabMemoized = useCallback(() => { var _filteredFocusableEle3; const filteredFocusableElements = getFilteredFocusableElements(wrapperRef === null || wrapperRef === void 0 ? void 0 : wrapperRef.current); (_filteredFocusableEle3 = filteredFocusableElements[selectedItemIndex.current]) === null || _filteredFocusableEle3 === void 0 ? void 0 : _filteredFocusableEle3.focus(); }, []); const handleTabLocal = () => { const filteredFocusableElements = getFilteredFocusableElements(wrapperRef === null || wrapperRef === void 0 ? void 0 : wrapperRef.current); filteredFocusableElements.forEach(element => { element.setAttribute('tabindex', '-1'); }); filteredFocusableElements[selectedItemIndex.current].setAttribute('tabindex', '0'); }; const focusAndScrollToElement = (element, scrollToElement = true) => { if (scrollToElement) { element === null || element === void 0 ? void 0 : element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); } element.focus(); }; const submenuKeydownHandleContext = useMemo(() => ({ handleArrowLeft: handleArrowLeftMemoized, handleArrowRight: handleArrowRightMemoized, handleTab: handleTabMemoized }), [handleArrowLeftMemoized, handleArrowRightMemoized, handleTabMemoized]); useLayoutEffect(() => { if (!wrapperRef.current || disableArrowKeyNavigation) { return; } const { current: element } = wrapperRef; /** * To handle the key events on the list * @param event */ const handleKeyDown = event => { var _document$querySelect, _document$querySelect2, _wrapperRef$current, _wrapperRef$current2; // To trap the focus inside the horizontal toolbar for left and right arrow keys const targetElement = event.target; if (targetElement instanceof HTMLElement && !targetElement.closest(`${childComponentSelector}`)) { return; } if (targetElement instanceof HTMLElement && (_document$querySelect = document.querySelector('[data-role="droplistContent"], [data-test-id="color-picker-menu"], [data-emoji-picker-container="true"]')) !== null && _document$querySelect !== void 0 && _document$querySelect.contains(targetElement) || targetElement instanceof HTMLElement && (_document$querySelect2 = document.querySelector('[data-test-id="color-picker-menu"]')) !== null && _document$querySelect2 !== void 0 && _document$querySelect2.contains(targetElement) || event.key === 'ArrowUp' || event.key === 'ArrowDown' || disableArrowKeyNavigation) { return; } const menuWrapper = document.querySelector('.menu-key-handler-wrapper'); if (menuWrapper) { // if menu wrapper exists, then a menu is open and arrow keys will be handled by MenuArrowKeyNavigationProvider return; } const elementBrowser = wrapperRef === null || wrapperRef === void 0 ? void 0 : (_wrapperRef$current = wrapperRef.current) === null || _wrapperRef$current === void 0 ? void 0 : _wrapperRef$current.querySelector(`#${ELEMENT_BROWSER_ID}`); if (elementBrowser) { // if element browser is open, then arrow keys will be handled by MenuArrowKeyNavigationProvider return; } const filteredFocusableElements = getFilteredFocusableElements(wrapperRef === null || wrapperRef === void 0 ? void 0 : wrapperRef.current); if (!filteredFocusableElements || (filteredFocusableElements === null || filteredFocusableElements === void 0 ? void 0 : filteredFocusableElements.length) === 0) { return; } // If the target element is the media picker then navigation is handled by the media picker if (targetElement instanceof HTMLElement && targetElement.closest(`[aria-label="${intl.formatMessage(mediaInsertMessages.mediaPickerPopupAriaLabel)}"]`)) { return; } if (targetElement instanceof HTMLElement && !((_wrapperRef$current2 = wrapperRef.current) !== null && _wrapperRef$current2 !== void 0 && _wrapperRef$current2.contains(targetElement))) { selectedItemIndex.current = -1; } else { selectedItemIndex.current = targetElement instanceof HTMLElement && filteredFocusableElements.indexOf(targetElement) > -1 ? filteredFocusableElements.indexOf(targetElement) : selectedItemIndex.current; } // do not scroll to focused element for sticky toolbar when navigating with arrows to avoid unnesessary scroll jump const allowScrollToElement = !(editorAppearance === 'comment' && !!useStickyToolbar); switch (event.key) { case 'ArrowRight': incrementIndex(filteredFocusableElements); focusAndScrollToElement(filteredFocusableElements[selectedItemIndex.current], allowScrollToElement); event.preventDefault(); break; case 'ArrowLeft': decrementIndex(filteredFocusableElements); focusAndScrollToElement(filteredFocusableElements[selectedItemIndex.current], allowScrollToElement); event.preventDefault(); break; case 'Tab': handleTabLocal(); break; case 'Escape': // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion handleEscape(event); break; default: } }; const globalKeyDownHandler = event => { // To focus the first element in the toolbar // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (isShortcutToFocusToolbar(event)) { var _filteredFocusableEle4, _filteredFocusableEle5, _filteredFocusableEle6; const filteredFocusableElements = getFilteredFocusableElements(wrapperRef === null || wrapperRef === void 0 ? void 0 : wrapperRef.current); (_filteredFocusableEle4 = filteredFocusableElements[0]) === null || _filteredFocusableEle4 === void 0 ? void 0 : _filteredFocusableEle4.focus(); // Button component from DS removes focus ring when :focus:not(:focus-visible) is true // Since here we programmatically focus the first button in the toolbar (as suppose to keyboard navigation), browser does not always force focus-visible to true // which is why the first button in the toolbar is not shown with focus ring // The workaround is add a new classname so we add back focus ring when the button is focused if (((_filteredFocusableEle5 = filteredFocusableElements[0]) === null || _filteredFocusableEle5 === void 0 ? void 0 : _filteredFocusableEle5.tagName) === 'BUTTON') { filteredFocusableElements[0].classList.add('first-floating-toolbar-button'); } (_filteredFocusableEle6 = filteredFocusableElements[0]) === null || _filteredFocusableEle6 === void 0 ? void 0 : _filteredFocusableEle6.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); } }; // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element === null || element === void 0 ? void 0 : element.addEventListener('keydown', handleKeyDown); const editorViewDom = editorView === null || editorView === void 0 ? void 0 : editorView.dom; if (isShortcutToFocusToolbar) { // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners editorViewDom === null || editorViewDom === void 0 ? void 0 : editorViewDom.addEventListener('keydown', globalKeyDownHandler); } return () => { // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners element === null || element === void 0 ? void 0 : element.removeEventListener('keydown', handleKeyDown); if (isShortcutToFocusToolbar) { // Ignored via go/ees005 // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners editorViewDom === null || editorViewDom === void 0 ? void 0 : editorViewDom.removeEventListener('keydown', globalKeyDownHandler); } }; }, [selectedItemIndex, wrapperRef, editorView, disableArrowKeyNavigation, handleEscape, childComponentSelector, incrementIndex, decrementIndex, isShortcutToFocusToolbar, editorAppearance, useStickyToolbar, intl]); return jsx("div", { css: editorAppearance === 'comment' && centeredToolbarContainer // eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- Ignored via go/DSP-18766 , className: "custom-key-handler-wrapper", ref: wrapperRef, role: "toolbar", "aria-label": intl.formatMessage(fullPageMessages.toolbarLabel), "aria-controls": EDIT_AREA_ID }, jsx(KeyDownHandlerContext.Provider, { value: submenuKeydownHandleContext }, children)); }; function getFocusableElements(rootNode) { if (!rootNode) { return []; } const focusableModalElements = rootNode.querySelectorAll('a[href], button:not([disabled]), textarea, input, select, div[tabindex="-1"], div[tabindex="0"]') || []; return Array.from(focusableModalElements); } function isElementOrAncestorHiddenOrDisabled(element, rootNode) { let currentElement = element; while (currentElement && currentElement !== rootNode && currentElement !== document.body) { const style = window.getComputedStyle(currentElement); // Check if current element is hidden if (style.visibility === 'hidden' || style.display === 'none') { return true; } // Check if current element is disabled if (currentElement.hasAttribute('disabled') || currentElement.getAttribute('aria-disabled') === 'true' || currentElement.disabled === true) { return true; } // Move to parent element currentElement = currentElement.parentElement; } return false; } function getFilteredFocusableElements(rootNode) { // The focusable elements from child components such as dropdown menus / popups are ignored return getFocusableElements(rootNode).filter(elm => { // Check if element or any ancestor is hidden or disabled const isHiddenOrDisabled = isElementOrAncestorHiddenOrDisabled(elm, rootNode); return !elm.closest('[data-role="droplistContent"]') && !elm.closest('[data-emoji-picker-container="true"]') && !elm.closest('[data-test-id="color-picker-menu"]') && !elm.closest('.scroll-buttons') && !isHiddenOrDisabled; }); }