diff --git a/eslint.config.mjs b/eslint.config.mjs index cd41e502778..23244f929ba 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -250,6 +250,7 @@ export default [{ "rsp-rules/no-react-key": [ERROR], "rsp-rules/sort-imports": [ERROR], "rsp-rules/no-non-shadow-contains": [ERROR], + "rsp-rules/faster-node-contains": [ERROR], "rulesdir/imports": [ERROR], "rulesdir/useLayoutEffectRule": [ERROR], "rulesdir/pure-render": [ERROR], @@ -430,6 +431,7 @@ export default [{ "rsp-rules/act-events-test": ERROR, "rsp-rules/no-getByRole-toThrow": ERROR, "rsp-rules/no-non-shadow-contains": OFF, + "rsp-rules/faster-node-contains": OFF, "rulesdir/imports": OFF, "monorepo/no-internal-import": OFF, "jsdoc/require-jsdoc": OFF @@ -508,6 +510,7 @@ export default [{ ], rules: { + "rsp-rules/faster-node-contains": OFF, "rsp-rules/no-non-shadow-contains": OFF, }, }, { diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index f228c77b477..78bff4f50af 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -13,7 +13,7 @@ import {AriaRangeCalendarProps, DateValue} from '@react-types/calendar'; import {CalendarAria, useCalendarBase} from './useCalendarBase'; import {FocusableElement, RefObject} from '@react-types/shared'; -import {nodeContains, useEvent} from '@react-aria/utils'; +import {isFocusWithin, nodeContains, useEvent} from '@react-aria/utils'; import {RangeCalendarState} from '@react-stately/calendar'; import {useRef} from 'react'; @@ -52,7 +52,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr let target = e.target as Element; if ( ref.current && - nodeContains(ref.current, document.activeElement) && + isFocusWithin(ref.current) && (!nodeContains(ref.current, target) || !target.closest('button, [role="button"]')) ) { state.selectFocusedDate(); diff --git a/packages/@react-aria/dialog/src/useDialog.ts b/packages/@react-aria/dialog/src/useDialog.ts index eef23f9968c..3094022f80f 100644 --- a/packages/@react-aria/dialog/src/useDialog.ts +++ b/packages/@react-aria/dialog/src/useDialog.ts @@ -12,7 +12,7 @@ import {AriaDialogProps} from '@react-types/dialog'; import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {filterDOMProps, nodeContains, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, isFocusWithin, useSlotId} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {useEffect, useRef} from 'react'; import {useOverlayFocusContain} from '@react-aria/overlays'; @@ -40,7 +40,7 @@ export function useDialog(props: AriaDialogProps, ref: RefObject { - if (ref.current && !nodeContains(ref.current, document.activeElement)) { + if (ref.current && !isFocusWithin(ref.current)) { focusSafely(ref.current); // Safari on iOS does not move the VoiceOver cursor to the dialog diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index a7ee80f5ec6..950a8f0e664 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -13,7 +13,7 @@ import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared'; import {focusSafely, isFocusVisible} from '@react-aria/interactions'; import {getFocusableTreeWalker} from '@react-aria/focus'; -import {getScrollParent, mergeProps, nodeContains, scrollIntoViewport} from '@react-aria/utils'; +import {getScrollParent, isFocusWithin, mergeProps, nodeContains, scrollIntoViewport} from '@react-aria/utils'; import {GridCollection, GridNode} from '@react-types/grid'; import {gridMap} from './utils'; import {GridState} from '@react-stately/grid'; @@ -75,7 +75,7 @@ export function useGridCell>(props: GridCellProps let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { // If focus is already on a focusable child within the cell, early return so we don't shift focus - if (nodeContains(ref.current, document.activeElement) && ref.current !== document.activeElement) { + if (isFocusWithin(ref.current) && ref.current !== document.activeElement) { return; } @@ -90,7 +90,7 @@ export function useGridCell>(props: GridCellProps if ( (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !nodeContains(ref.current, document.activeElement) + !isFocusWithin(ref.current) ) { focusSafely(ref.current); } diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 6a1bd8e27c1..9cae838885c 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getScrollParent, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; +import {chain, getScrollParent, isFocusWithin, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; import {getRowId, listMap} from './utils'; @@ -79,7 +79,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt if ( ref.current !== null && ((keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !nodeContains(ref.current, document.activeElement)) + !isFocusWithin(ref.current)) ) { focusSafely(ref.current); } diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts index 692d434981b..34001d640c1 100644 --- a/packages/@react-aria/landmark/src/useLandmark.ts +++ b/packages/@react-aria/landmark/src/useLandmark.ts @@ -325,7 +325,7 @@ class LandmarkManager implements LandmarkManagerApi { private focusMain() { let main = this.getLandmarkByRole('main'); - if (main && main.ref.current && nodeContains(document, main.ref.current)) { + if (main && main.ref.current && main.ref.current.isConnected) { this.focusLandmark(main.ref.current, 'forward'); return true; } @@ -352,7 +352,7 @@ class LandmarkManager implements LandmarkManagerApi { } // Otherwise, focus the landmark itself - if (nextLandmark.ref.current && nodeContains(document, nextLandmark.ref.current)) { + if (nextLandmark.ref.current && nextLandmark.ref.current.isConnected) { this.focusLandmark(nextLandmark.ref.current, backward ? 'backward' : 'forward'); return true; } diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 14df02c2243..1be44f8a931 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -14,7 +14,7 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays'; import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, nodeContains, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, isFocusWithin, nodeContains, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; import type {SubmenuTriggerState} from '@react-stately/menu'; import {useCallback, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -100,7 +100,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm let submenuKeyDown = (e: KeyboardEvent) => { // If focus is not within the menu, assume virtual focus is being used. // This means some other input element is also within the popover, so we shouldn't close the menu. - if (!nodeContains(e.currentTarget, document.activeElement)) { + if (!isFocusWithin(e.currentTarget)) { return; } diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index ee218a5b7ca..7980f406e3f 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -12,7 +12,7 @@ import {calculatePosition, getRect, PositionResult} from './calculatePosition'; import {DOMAttributes, RefObject} from '@react-types/shared'; -import {nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; +import {isFocusWithin, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; @@ -154,7 +154,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { // so it can be restored after repositioning. This way if the overlay height // changes, the focused element appears to stay in the same position. let anchor: ScrollAnchor | null = null; - if (scrollRef.current && nodeContains(scrollRef.current, document.activeElement)) { + if (scrollRef.current && isFocusWithin(scrollRef.current)) { let anchorRect = document.activeElement?.getBoundingClientRect(); let scrollRect = scrollRef.current.getBoundingClientRect(); // Anchor from the top if the offset is in the top half of the scrollable element, diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 7407e576d61..e27dc3f71ac 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isTabbable, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isFocusWithin, isTabbable, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; @@ -314,7 +314,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // If the active element is NOT tabbable but is contained by an element that IS tabbable (aka the cell), the browser will actually move focus to // the containing element. We need to special case this so that tab will move focus out of the grid instead of looping between // focusing the containing cell and back to the non-tabbable child element - if (next && (!nodeContains(next, document.activeElement) || (document.activeElement && !isTabbable(document.activeElement)))) { + if (next && (!isFocusWithin(next) || (document.activeElement && !isTabbable(document.activeElement)))) { focusWithoutScrolling(next); } } @@ -379,7 +379,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let element = getItemElement(ref, manager.focusedKey); if (element instanceof HTMLElement) { // This prevents a flash of focus on the first/last element in the collection, or the collection itself. - if (!nodeContains(element, document.activeElement) && !shouldUseVirtualFocus) { + if (!isFocusWithin(element) && !shouldUseVirtualFocus) { focusWithoutScrolling(element); } diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 9da3461dd5b..be51b95cd7f 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -12,7 +12,7 @@ export {useId, mergeIds, useSlotId} from './useId'; export {chain} from './chain'; export {createShadowTreeWalker, ShadowTreeWalker} from './shadowdom/ShadowTreeWalker'; -export {getActiveElement, getEventTarget, nodeContains} from './shadowdom/DOMFunctions'; +export {getActiveElement, getEventTarget, nodeContains, isFocusWithin} from './shadowdom/DOMFunctions'; export {getOwnerDocument, getOwnerWindow, isShadowRoot} from './domHelpers'; export {mergeProps} from './mergeProps'; export {mergeRefs} from './mergeRefs'; diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 336b0ebc2c9..f20aa9c9759 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -99,7 +99,7 @@ function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'| * the body (e.g. targetElement is in a popover), this will only scroll the scroll parents of the targetElement up to but not including the body itself. */ export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollIntoViewportOpts): void { - if (targetElement && nodeContains(document, targetElement)) { + if (targetElement && targetElement.isConnected) { let root = document.scrollingElement || document.documentElement; let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; // If scrolling is not currently prevented then we aren’t in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index bb69beb6b08..12c7322e0fa 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -1,7 +1,7 @@ // Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 /* eslint-disable rsp-rules/no-non-shadow-contains */ -import {isShadowRoot} from '../domHelpers'; +import {getOwnerWindow, isShadowRoot} from '../domHelpers'; import {shadowDOM} from '@react-stately/flags'; /** @@ -69,3 +69,24 @@ export function getEventTarget(event: T): Element { } return event.target as Element; } + +/** + * ShadowDOM safe fast version of node.contains(document.activeElement). + * @param node + * @returns + */ +export function isFocusWithin(node: Element | null | undefined): boolean { + if (!node) { + return false; + } + // Get the active element within the node's parent shadow root (or the document). Can return null. + let root = node.getRootNode(); + let ownerWindow = getOwnerWindow(node); + if (!(root instanceof ownerWindow.Document || root instanceof ownerWindow.ShadowRoot)) { + return false; + } + let activeElement = root.activeElement; + + // Check if the active element is within this node. These nodes are within the same shadow root. + return activeElement != null && node.contains(activeElement); +} diff --git a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx index 54051eede48..4c8fea65416 100644 --- a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx +++ b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx @@ -15,7 +15,7 @@ import {DOMRefValue, ItemProps, Key} from '@react-types/shared'; import {FocusScope} from '@react-aria/focus'; import {getInteractionModality} from '@react-aria/interactions'; import helpStyles from '@adobe/spectrum-css-temp/components/contextualhelp/vars.css'; -import {nodeContains} from '@react-aria/utils'; +import {isFocusWithin, nodeContains} from '@react-aria/utils'; import {Popover} from './Popover'; import React, {JSX, KeyboardEventHandler, ReactElement, useEffect, useRef, useState} from 'react'; import ReactDOM from 'react-dom'; @@ -99,7 +99,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem setTraySubmenuAnimation('spectrum-TraySubmenu-exit'); setTimeout(() => { submenuTriggerState.close(); - if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) { + if (parentMenuRef.current && !isFocusWithin(parentMenuRef.current)) { parentMenuRef.current.focus(); } }, 220); // Matches transition duration diff --git a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx index 061e40d55b7..bfb80cfcf2b 100644 --- a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx @@ -11,9 +11,9 @@ */ import {classNames, useIsMobileDevice} from '@react-spectrum/utils'; +import {isFocusWithin, mergeProps} from '@react-aria/utils'; import {Key} from '@react-types/shared'; import {MenuContext, SubmenuTriggerContext, useMenuStateContext} from './context'; -import {mergeProps, nodeContains} from '@react-aria/utils'; import {Popover} from './Popover'; import React, {type JSX, ReactElement, useRef} from 'react'; import ReactDOM from 'react-dom'; @@ -49,7 +49,7 @@ function SubmenuTrigger(props: SubmenuTriggerProps) { let isMobile = useIsMobileDevice(); let onBackButtonPress = () => { submenuTriggerState.close(); - if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) { + if (parentMenuRef.current && !isFocusWithin(parentMenuRef.current)) { parentMenuRef.current.focus(); } }; diff --git a/packages/@react-spectrum/menu/src/useOverlayPosition.ts b/packages/@react-spectrum/menu/src/useOverlayPosition.ts index ee218a5b7ca..7980f406e3f 100644 --- a/packages/@react-spectrum/menu/src/useOverlayPosition.ts +++ b/packages/@react-spectrum/menu/src/useOverlayPosition.ts @@ -12,7 +12,7 @@ import {calculatePosition, getRect, PositionResult} from './calculatePosition'; import {DOMAttributes, RefObject} from '@react-types/shared'; -import {nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; +import {isFocusWithin, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; @@ -154,7 +154,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { // so it can be restored after repositioning. This way if the overlay height // changes, the focused element appears to stay in the same position. let anchor: ScrollAnchor | null = null; - if (scrollRef.current && nodeContains(scrollRef.current, document.activeElement)) { + if (scrollRef.current && isFocusWithin(scrollRef.current)) { let anchorRect = document.activeElement?.getBoundingClientRect(); let scrollRect = scrollRef.current.getBoundingClientRect(); // Anchor from the top if the offset is in the top half of the scrollable element, diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index b5e66fd84a2..38cfe17b600 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -59,7 +59,7 @@ import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; import {ColumnSize} from '@react-types/table'; import {CustomDialog, DialogContainer} from '..'; import {DOMProps, DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LinkDOMProps, LoadingState, Node} from '@react-types/shared'; -import {getActiveElement, getOwnerDocument, nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils'; +import {getActiveElement, getOwnerDocument, isFocusWithin, nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; import {IconContext} from './Icon'; // @ts-ignore @@ -1301,7 +1301,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, onOpenChange={setIsOpen} ref={popoverRef} shouldCloseOnInteractOutside={() => { - if (!nodeContains(popoverRef.current, document.activeElement)) { + if (!isFocusWithin(popoverRef.current)) { return false; } formRef.current?.requestSubmit(); diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 443303f5fb2..3778641017a 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -33,7 +33,7 @@ import {GridNode} from '@react-types/grid'; import {InsertionIndicator} from './InsertionIndicator'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {isAndroid, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; +import {isAndroid, isFocusWithin, mergeProps, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; import {LayoutInfo, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {layoutInfoToStyle, ScrollView, setScrollLeft, VirtualizerItem} from '@react-aria/virtualizer'; @@ -606,7 +606,7 @@ function TableVirtualizer(props: TableVirtualizerProps) { // only that it changes in a resize, and when that happens, we want to sync the body to the // header scroll position useEffect(() => { - if (getInteractionModality() === 'keyboard' && headerRef.current && nodeContains(headerRef.current, document.activeElement) && bodyRef.current) { + if (getInteractionModality() === 'keyboard' && headerRef.current && isFocusWithin(headerRef.current) && bodyRef.current) { scrollIntoView(headerRef.current, document.activeElement as HTMLElement); scrollIntoViewport(document.activeElement, {containingElement: domRef.current}); bodyRef.current.scrollLeft = headerRef.current.scrollLeft; diff --git a/packages/dev/eslint-plugin-rsp-rules/index.js b/packages/dev/eslint-plugin-rsp-rules/index.js index 4bb1265f0fc..aba60c2f759 100644 --- a/packages/dev/eslint-plugin-rsp-rules/index.js +++ b/packages/dev/eslint-plugin-rsp-rules/index.js @@ -11,6 +11,7 @@ */ import actEventsTest from './rules/act-events-test.js'; +import fasterNodeContains from './rules/faster-node-contains.js'; import noGetByRoleToThrow from './rules/no-getByRole-toThrow.js'; import noNonShadowContains from './rules/no-non-shadow-contains.js'; import noReactKey from './rules/no-react-key.js'; @@ -21,7 +22,8 @@ const rules = { 'no-getByRole-toThrow': noGetByRoleToThrow, 'no-react-key': noReactKey, 'sort-imports': sortImports, - 'no-non-shadow-contains': noNonShadowContains + 'no-non-shadow-contains': noNonShadowContains, + 'faster-node-contains': fasterNodeContains }; const meta = { diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/faster-node-contains.js b/packages/dev/eslint-plugin-rsp-rules/rules/faster-node-contains.js new file mode 100644 index 00000000000..65cdcef2ab8 --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/rules/faster-node-contains.js @@ -0,0 +1,141 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const plugin = { + meta: { + type: 'suggestion', + docs: { + description: 'Optimize nodeContains calls by using faster alternatives like :focus-within and isConnected', + recommended: true + }, + fixable: 'code', + messages: { + useFocusWithin: 'Use isFocusWithin(element) instead of nodeContains for activeElement checks.', + useIsConnected: 'Use node.isConnected instead of nodeContains for document contains checks.' + } + }, + create: (context) => { + let existingReactAriaUtilsImport = null; + let hasIsFocusWithinImport = false; + + return { + // Track imports from @react-aria/utils + ImportDeclaration(node) { + if ( + node.source && + node.source.type === 'Literal' && + node.source.value === '@react-aria/utils' + ) { + existingReactAriaUtilsImport = node; + hasIsFocusWithinImport = node.specifiers.some( + spec => + spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'isFocusWithin' + ); + } + }, + + // Detect nodeContains() function calls + CallExpression(node) { + if (node.callee.type === 'Identifier' && node.callee.name === 'nodeContains') { + const sourceCode = context.sourceCode; + + // nodeContains should have exactly 2 arguments + if (node.arguments.length === 2) { + const firstArg = node.arguments[0]; + const secondArg = node.arguments[1]; + + if (isDocumentActiveElement(secondArg)) { + // Case 1: Check if second argument is document.activeElement + const elementText = sourceCode.getText(firstArg); + + context.report({ + node, + messageId: 'useFocusWithin', + fix: (fixer) => { + const fixes = [fixer.replaceText(node, `isFocusWithin(${elementText})`)]; + + // Add import if not present + if (!hasIsFocusWithinImport) { + if (existingReactAriaUtilsImport) { + const specifiers = existingReactAriaUtilsImport.specifiers; + if (specifiers.length > 0) { + const openBrace = sourceCode.getFirstToken( + existingReactAriaUtilsImport, + token => token.value === '{' + ); + if (openBrace) { + fixes.push( + fixer.insertTextAfter(openBrace, 'isFocusWithin, ') + ); + } + } + } else { + const programNode = context.sourceCode.ast; + const imports = programNode.body.filter( + n => n.type === 'ImportDeclaration' + ); + const importStatement = + "\nimport {isFocusWithin} from '@react-aria/utils';"; + + if (imports.length > 0) { + const lastImport = imports[imports.length - 1]; + fixes.push(fixer.insertTextAfter(lastImport, importStatement)); + } else { + fixes.push( + fixer.insertTextBefore( + programNode.body[0], + "import {isFocusWithin} from '@react-aria/utils';\n" + ) + ); + } + } + } + + return fixes; + } + }); + } else if (isDocument(firstArg)) { + // Case 2: Check if first argument is document + const nodeText = sourceCode.getText(secondArg); + + context.report({ + node, + messageId: 'useIsConnected', + fix: (fixer) => { + return fixer.replaceText(node, `${nodeText}.isConnected`); + } + }); + } + } + } + } + }; + } +}; + +function isDocumentActiveElement(node) { + return ( + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === 'document' && + node.property.type === 'Identifier' && + node.property.name === 'activeElement' + ); +} + +function isDocument(node) { + return node.type === 'Identifier' && node.name === 'document'; +} + +export default plugin; diff --git a/packages/dev/eslint-plugin-rsp-rules/test/faster-node-contains.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/faster-node-contains.test-lint.js new file mode 100644 index 00000000000..e68c07ce555 --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/test/faster-node-contains.test-lint.js @@ -0,0 +1,92 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import fasterNodeContainsRule from '../rules/faster-node-contains.js'; +import {RuleTester} from 'eslint'; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2015, + sourceType: 'module' + } +}); + +// Throws error if the tests in ruleTester.run() do not pass +ruleTester.run( + 'faster-node-contains', + fasterNodeContainsRule, + { + // 'valid' checks cases that should pass + valid: [ + { + code: ` +if (nodeContains(element, other)) { + console.log('contained'); +}` + } + ], + // 'invalid' checks cases that should not pass + invalid: [ + { + code: ` +if (nodeContains(element, document.activeElement)) { + console.log('contained'); +}`, + output: ` +import {isFocusWithin} from '@react-aria/utils'; +if (isFocusWithin(element)) { + console.log('contained'); +}`, + errors: 1 + }, + { + code: ` +if (nodeContains(document, other)) { + console.log('connected'); +}`, + output: ` +if (other.isConnected) { + console.log('connected'); +}`, + errors: 1 + }, + // When @react-aria/utils is already imported, add isFocusWithin to that import + { + code: ` +import {nodeContains} from '@react-aria/utils'; +if (nodeContains(element, document.activeElement)) { + console.log('contained'); +}`, + output: ` +import {isFocusWithin, nodeContains} from '@react-aria/utils'; +if (isFocusWithin(element)) { + console.log('contained'); +}`, + errors: 1 + }, + // When isFocusWithin is already imported, only replace the call + { + code: ` +import {isFocusWithin, nodeContains} from '@react-aria/utils'; +if (nodeContains(element, document.activeElement)) { + console.log('contained'); +}`, + output: ` +import {isFocusWithin, nodeContains} from '@react-aria/utils'; +if (isFocusWithin(element)) { + console.log('contained'); +}`, + errors: 1 + } + ] + } +); diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index 144f8515b44..6d0befb0aaf 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -21,7 +21,7 @@ import { useContextProps, useRenderProps } from './utils'; -import {filterDOMProps, mergeProps, nodeContains, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, isFocusWithin, mergeProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {OverlayArrowContext} from './OverlayArrow'; import {OverlayTriggerProps, OverlayTriggerState, useOverlayTriggerState} from 'react-stately'; @@ -199,7 +199,7 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, clearContexts // Focus the popover itself on mount, unless a child element is already focused. // Skip this for submenus since hovering a submenutrigger should keep focus on the trigger useEffect(() => { - if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !nodeContains(ref.current, document.activeElement)) { + if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !isFocusWithin(ref.current)) { focusSafely(ref.current); } }, [isDialog, ref, props.trigger]);