UNPKG

@clayui/shared

Version:

ClayShared component

240 lines (225 loc) 9.18 kB
/** * SPDX-FileCopyrightText: © 2019 Liferay, Inc. <https://liferay.com> * SPDX-License-Identifier: BSD-3-Clause */ import React from 'react'; // https://github.com/facebook/react/blob/master/packages/shared/ReactWorkTags.js#L39 const HostComponent = 5; let minimalTabIndex = 0; export function isFocusable(_ref) { let { contentEditable, disabled, href, offsetParent, rel, tabIndex, tagName, type } = _ref; // Normalize casing tagName = tagName?.toLowerCase(); // Hack to check if element is visible if (!offsetParent) { return false; } if (disabled) { return false; } if (tabIndex != null && tabIndex < minimalTabIndex) { return false; } if (tabIndex != null && tabIndex >= minimalTabIndex || contentEditable === true || contentEditable === 'true') { return true; } if (tagName === 'a' || tagName === 'area') { return !!href && rel !== 'ignore'; } if (tagName === 'input') { return type !== 'file' && type !== 'hidden'; } return tagName === 'button' || tagName === 'embed' || tagName === 'iframe' || tagName === 'object' || tagName === 'select' || tagName === 'textarea'; } const FOCUS_SCOPE_MARKERS = ['span[data-focus-scope-end="true"]', 'span[data-focus-scope-start="true"]']; export const FOCUSABLE_ELEMENTS = ['a[href]', '[contenteditable]', '[tabindex]:not([tabindex^="-"])', 'area[href]', 'button:not([disabled])', 'embed', 'iframe', 'input:not([disabled]):not([type="hidden"])', 'object', 'select:not([disabled]):not([aria-hidden])', 'textarea:not([disabled]):not([aria-hidden])']; // A switcher that helps define which fiber to use to navigate, the // component's current fiber or the fiber in progress. let hasSibling = false; function collectDocumentFocusTargets() { const focusTargets = [...FOCUSABLE_ELEMENTS, ...FOCUS_SCOPE_MARKERS]; return Array.from(document.querySelectorAll(focusTargets.join(','))).filter(element => { const isFocusScopeMarker = element.dataset['focusScopeEnd'] || element.dataset['focusScopeStart']; if (isFocusable(element) || isFocusScopeMarker) { return window.getComputedStyle(element).visibility !== 'hidden'; } return false; }); } // https://github.com/facebook/react/pull/15849#diff-39a673d38713257d5fe7d90aac2acb5aR107 const isFiberFocusable = fiber => { const { memoizedProps, stateNode, type } = fiber; // The element may be having an update in progress. if (memoizedProps === null) { return false; } return isFocusable({ contentEditable: memoizedProps.contentEditable, disabled: memoizedProps.disabled, href: memoizedProps.href, offsetParent: stateNode.offsetParent, rel: memoizedProps.rel, tabIndex: memoizedProps.tabIndex, tagName: type, type: memoizedProps.type }); }; const isFiberFocusScopeMarker = fiber => { return fiber.stateNode.dataset['focusScopeEnd'] || fiber.stateNode.dataset['focusScopeStart']; }; const collectFocusTargets = (node, focusTargets) => { const isFiberFocusTarget = node.tag === HostComponent && (isFiberFocusable(node) || isFiberFocusScopeMarker(node)); if (isFiberFocusTarget) { focusTargets.push(node.stateNode); } const child = node.child; if (child !== null) { collectFocusTargets(child, focusTargets); } const sibling = node.sibling; if (sibling) { hasSibling = true; collectFocusTargets(sibling, focusTargets); } }; const getFiber = scope => { if (!scope.current) { return null; } const internalKey = Object.keys(scope.current).find(key => key.indexOf('__reactInternalInstance') === 0 || key.indexOf('__reactFiber') === 0); if (internalKey) { return scope.current[internalKey]; } return null; }; const getFocusTargetsInScope = fiberNode => { const focusTargets = []; const { child } = fiberNode; if (child !== null) { collectFocusTargets(child, focusTargets); } return focusTargets; }; export function useFocusManagement(scope) { const nextFocusInDocRef = React.useRef(null); const prevFocusInDocRef = React.useRef(null); const moveFocusInScope = function (scope) { let backwards = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; let persistOnScope = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; let fiberFocusTargets = getFocusTargetsInScope(scope.alternate ?? scope); // When browsing the alternate/in progress fiber if don't find sibling // elements that might correspond to a React.Portal try searching for // focus targets using the current fiber. if (!hasSibling) { fiberFocusTargets = getFocusTargetsInScope(scope); } else { // Just resets the value for the next focus iteration. hasSibling = false; } if (fiberFocusTargets.length === 0) { return null; } const activeElement = document.activeElement; if (!activeElement) { return; } const docFocusTargets = collectDocumentFocusTargets(); const docPosition = docFocusTargets.indexOf(activeElement); const reactFiberPosition = fiberFocusTargets.indexOf(activeElement); const startFocusTrap = fiberFocusTargets.find(element => element.getAttribute('data-focus-scope-start') === 'true'); const endFocusTrap = fiberFocusTargets.find(element => element.getAttribute('data-focus-scope-end') === 'true'); const nextFocusInDoc = docFocusTargets[docPosition + 1]; const prevFocusInDoc = docFocusTargets[docPosition - 1]; // Ignore when the active element is not in the scope. if (reactFiberPosition < 0 && !prevFocusInDocRef.current && !nextFocusInDocRef.current && nextFocusInDoc !== endFocusTrap && prevFocusInDoc !== startFocusTrap) { return null; } let nextFocusInFiber = fiberFocusTargets[reactFiberPosition + 1]; let prevFocusInFiber = fiberFocusTargets[reactFiberPosition - 1]; // If the focus is moving within the focus trap, let the browser handle // navigation and focus order. if (startFocusTrap && endFocusTrap && startFocusTrap !== prevFocusInDoc && endFocusTrap !== nextFocusInDoc) { return null; } // Checks if the focus has reached the end of the scope and should // go back to the beginning. if (endFocusTrap && endFocusTrap === nextFocusInDoc) { nextFocusInFiber = docFocusTargets.find((_, index, array) => array[index - 1] === startFocusTrap); } // Checks if the focus has arrived at the beginning of the scope and is // returning moves the focus to the end of the scope. if (startFocusTrap && startFocusTrap === prevFocusInDoc) { prevFocusInFiber = docFocusTargets.find((_, index, array) => array[index + 1] === endFocusTrap); } // Only moves to the next element if it is in scope. if (persistOnScope && (!nextFocusInFiber || backwards && !prevFocusInFiber)) { return null; } // If these two nodes are not equal, that means React is likely using // a portal to render the node in a different part of the DOM. When // this happens, we want to track where the next node is in case we // reach the end of the list of focus targets. if (nextFocusInFiber !== nextFocusInDoc) { nextFocusInDocRef.current = nextFocusInDoc; } // Same as above, except we track the previous node for tabbing backwards. if (prevFocusInFiber !== prevFocusInDoc) { prevFocusInDocRef.current = prevFocusInDoc; } let nextActive = backwards ? prevFocusInFiber : nextFocusInFiber; // We track the previous and next elements in the document flow due to React portals. // // Consider the following structure of dom elements and where a React Tree is: // // <HtmlFocusElement1> // <ReactTree> // <HtmlFocusElement2> // // When our focus gets to the end of the React Tree, we want to focus HtmlFocusElement2 // When we are at the beginning of the React Tree and want to go backwards with // SHIFT + TAB, we want to focus HtmlFocusElement1. This allows the React Tree to // render nodes whereever it would like in the document. // // If there is no `nextActive`, that means we are either at the beginning or end of the // list of focus targets in the React Tree. So we go back to the flow of the // document instead of the flow of the React Tree. if (!nextActive) { nextActive = backwards ? prevFocusInDocRef.current : nextFocusInDocRef.current; } if (nextActive) { nextActive.focus(); if (nextActive === prevFocusInDocRef.current || nextActive === nextFocusInDocRef.current) { nextFocusInDocRef.current = null; prevFocusInDocRef.current = null; } return nextActive; } return null; }; return { focusFirst: () => { // eslint-disable-next-line react-compiler/react-compiler minimalTabIndex = -1; const next = moveFocusInScope(getFiber(scope), false, true); minimalTabIndex = 0; return next; }, focusNext: persistOnScope => moveFocusInScope(getFiber(scope), false, persistOnScope), focusPrevious: persistOnScope => moveFocusInScope(getFiber(scope), true, persistOnScope) }; }