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/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index 07415fc045e..b93667f0f69 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -309,7 +309,6 @@ function getMaxHeight( top: Math.max(boundaryDimensions.top + boundaryToContainerTransformOffset, (visualViewport?.offsetTop ?? boundaryDimensions.top) + boundaryToContainerTransformOffset), bottom: Math.min((boundaryDimensions.top + boundaryDimensions.height + boundaryToContainerTransformOffset), (visualViewport?.offsetTop ?? 0) + (visualViewport?.height ?? 0)) }; - let maxHeight = heightGrowthDirection !== 'top' ? // We want the distance between the top of the overlay to the bottom of the boundary Math.max(0, @@ -558,10 +557,33 @@ export function calculatePosition(opts: PositionOpts): PositionResult { // Otherwise this returns the height/width of a arbitrary boundary element, and its top/left with respect to the viewport (NOTE THIS MEANS IT DOESNT INCLUDE SCROLL) let boundaryDimensions = getContainerDimensions(boundaryElement, visualViewport); let containerDimensions = getContainerDimensions(container, visualViewport); - // If the container is the HTML element wrapping the body element, the retrieved scrollTop/scrollLeft will be equal to the - // body element's scroll. Set the container's scroll values to 0 since the overlay's edge position value in getDelta don't then need to be further offset - // by the container scroll since they are essentially the same containing element and thus in the same coordinate system - let containerOffsetWithBoundary: Offset = getPosition(boundaryElement, container, false); + + // There are several difference cases of how to calculate the containerOffsetWithBoundary: + // - boundaryElement is body or HTML and the container is an arbitrary element in the boundary (aka submenu with parent menu as container in v3) + // - boundaryElement and container are both body or HTML element (aka standard popover case) + // - boundaryElement is customized by the user. Container can also be arbitrary (either body/HTML or some other element) + // containerOffsetWithBoundary should always return a value that is the boundary's coordinate offset with respect to the container coord system (container is 0, 0) + let containerOffsetWithBoundary: Offset; + if ((boundaryElement.tagName === 'BODY' || boundaryElement.tagName === 'HTML') && !isViewportContainer) { + // Use getRect instead of getOffset because boundaryDimensions for BODY/HTML is in viewport coordinate space, + // not document coordinate space + let containerRect = getRect(container, false); + // the offset should be negative because if container is at viewport position x,y, then viewport top (aka 0) + // is at position -x,y in container-relative coordinates + containerOffsetWithBoundary = { + top: -(containerRect.top - boundaryDimensions.top), + left: -(containerRect.left - boundaryDimensions.left), + width: 0, + height: 0 + }; + } else if ((boundaryElement.tagName === 'BODY' || boundaryElement.tagName === 'HTML') && isViewportContainer) { + // both are the same viewport container, no offset needed + containerOffsetWithBoundary = {top: 0, left: 0, width: 0, height: 0}; + } else { + // This returns the boundary's coordinate with respect to the container. This case captures cases such as when you provide a custom boundary + // like in ScrollingBoundaryContainerExample in Popover.stories. + containerOffsetWithBoundary = getPosition(boundaryElement, container, false); + } let isContainerDescendentOfBoundary = nodeContains(boundaryElement, container); return calculatePositionInternal( 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..4e246b65ec2 100644 --- a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx +++ b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx @@ -15,8 +15,8 @@ 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 {Popover} from './Popover'; +import {isFocusWithin, nodeContains} from '@react-aria/utils'; +import {Popover} from '@react-spectrum/overlays'; import React, {JSX, KeyboardEventHandler, ReactElement, useEffect, useRef, useState} from 'react'; import ReactDOM from 'react-dom'; import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; @@ -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/Popover.tsx b/packages/@react-spectrum/menu/src/Popover.tsx deleted file mode 100644 index 51f43a769dd..00000000000 --- a/packages/@react-spectrum/menu/src/Popover.tsx +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright 2020 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 {AriaPopoverProps, DismissButton, PopoverAria} from '@react-aria/overlays'; -import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; -import {DOMRef, RefObject, StyleProps} from '@react-types/shared'; -import {FocusWithinProps, useFocusWithin} from '@react-aria/interactions'; -import {mergeProps, useLayoutEffect, useObjectRef} from '@react-aria/utils'; -import {Overlay} from '@react-spectrum/overlays'; -import {OverlayTriggerState} from '@react-stately/overlays'; -import overrideStyles from './overlays.css'; -import React, {ForwardedRef, forwardRef, ReactNode, useRef, useState} from 'react'; -import styles from '@adobe/spectrum-css-temp/components/popover/vars.css'; -import {Underlay} from './Underlay'; -import {usePopover} from './usePopover'; - -interface PopoverProps extends Omit, FocusWithinProps, StyleProps { - children: ReactNode, - hideArrow?: boolean, - state: OverlayTriggerState, - shouldContainFocus?: boolean, - onEntering?: () => void, - onEnter?: () => void, - onEntered?: () => void, - onExiting?: () => void, - onExited?: () => void, - onExit?: () => void, - container?: HTMLElement, - disableFocusManagement?: boolean, - enableBothDismissButtons?: boolean, - onDismissButtonPress?: () => void -} - -interface PopoverWrapperProps extends PopoverProps, FocusWithinProps { - isOpen?: boolean, - wrapperRef: RefObject -} - -interface ArrowProps { - arrowProps: PopoverAria['arrowProps'], - isLandscape: boolean, - arrowRef?: RefObject, - primary: number, - secondary: number, - borderDiagonal: number -} - -/** - * Arrow placement can be done pointing right or down because those paths start at 0, x or y. Because the - * other two don't, they start at a fractional pixel value, it introduces rounding differences between browsers and - * between display types (retina with subpixels vs not retina). By flipping them with CSS we can ensure that - * the path always starts at 0 so that it perfectly overlaps the popover's border. - * See bottom of file for more explanation. - */ -let arrowPlacement = { - left: 'right', - right: 'right', - top: 'bottom', - bottom: 'bottom' -}; - -export const Popover = forwardRef(function Popover(props: PopoverProps, ref: DOMRef) { - let { - children, - state, - ...otherProps - } = props; - let domRef = useDOMRef(ref); - let wrapperRef = useRef(null); - - return ( - - - {children} - - - ); -}); - -const PopoverWrapper = forwardRef((props: PopoverWrapperProps, ref: ForwardedRef) => { - let { - children, - isOpen, - hideArrow, - isNonModal, - enableBothDismissButtons, - state, - wrapperRef, - onDismissButtonPress = () => state.close() - } = props; - let {styleProps} = useStyleProps(props); - let objRef = useObjectRef(ref); - - let {size, borderWidth, arrowRef} = useArrowSize(); - const borderRadius = usePopoverBorderRadius(objRef); - let borderDiagonal = borderWidth * Math.SQRT2; - let primary = size + borderDiagonal; - let secondary = primary * 2; - let { - popoverProps, - arrowProps, - underlayProps, - placement - } = usePopover({ - ...props, - popoverRef: objRef, - maxHeight: undefined, - arrowSize: hideArrow ? 0 : secondary, - arrowBoundaryOffset: borderRadius - }, state); - let {focusWithinProps} = useFocusWithin(props); - - // Attach Transition's nodeRef to outermost wrapper for node.reflow: https://github.com/reactjs/react-transition-group/blob/c89f807067b32eea6f68fd6c622190d88ced82e2/src/Transition.js#L231 - return ( -
- {!isNonModal && } -
- {(!isNonModal || enableBothDismissButtons) && } - {children} - {hideArrow ? null : ( - - )} - -
-
- ); -}); - -function usePopoverBorderRadius(popoverRef: RefObject) { - let [borderRadius, setBorderRadius] = useState(0); - useLayoutEffect(() => { - if (popoverRef.current) { - let spectrumBorderRadius = window.getComputedStyle(popoverRef.current).borderRadius; - if (spectrumBorderRadius !== '') { - setBorderRadius(parseInt(spectrumBorderRadius, 10)); - } - } - }, [popoverRef]); - return borderRadius; -} - -function useArrowSize() { - let [size, setSize] = useState(20); - let [borderWidth, setBorderWidth] = useState(1); - let arrowRef = useRef(null); - // get the css value for the tip size and divide it by 2 for this arrow implementation - useLayoutEffect(() => { - if (arrowRef.current) { - let spectrumTipWidth = window.getComputedStyle(arrowRef.current) - .getPropertyValue('--spectrum-popover-tip-size'); - if (spectrumTipWidth !== '') { - setSize(parseInt(spectrumTipWidth, 10) / 2); - } - - let spectrumBorderWidth = window.getComputedStyle(arrowRef.current) - .getPropertyValue('--spectrum-popover-tip-borderWidth'); - if (spectrumBorderWidth !== '') { - setBorderWidth(parseInt(spectrumBorderWidth, 10)); - } - } - }, []); - return {size, borderWidth, arrowRef}; -} - -function Arrow(props: ArrowProps) { - let {primary, secondary, isLandscape, arrowProps, borderDiagonal, arrowRef} = props; - let halfBorderDiagonal = borderDiagonal / 2; - - let primaryStart = 0; - let primaryEnd = primary - halfBorderDiagonal; - - let secondaryStart = halfBorderDiagonal; - let secondaryMiddle = secondary / 2; - let secondaryEnd = secondary - halfBorderDiagonal; - - let pathData = isLandscape ? [ - 'M', secondaryStart, primaryStart, - 'L', secondaryMiddle, primaryEnd, - 'L', secondaryEnd, primaryStart - ] : [ - 'M', primaryStart, secondaryStart, - 'L', primaryEnd, secondaryMiddle, - 'L', primaryStart, secondaryEnd - ]; - - /* use ceil because the svg needs to always accommodate the path inside it */ - return ( - - - - ); -} diff --git a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx index 061e40d55b7..e9c2960d1d0 100644 --- a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx @@ -11,10 +11,10 @@ */ 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 {Popover} from '@react-spectrum/overlays'; import React, {type JSX, ReactElement, useRef} from 'react'; import ReactDOM from 'react-dom'; import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; @@ -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/Underlay.tsx b/packages/@react-spectrum/menu/src/Underlay.tsx deleted file mode 100644 index da9911834d8..00000000000 --- a/packages/@react-spectrum/menu/src/Underlay.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 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 {classNames} from '@react-spectrum/utils'; -import {isScrollable} from '@react-aria/utils'; -import React, {JSX} from 'react'; -import underlayStyles from '@adobe/spectrum-css-temp/components/underlay/vars.css'; - -interface UnderlayProps { - isOpen?: boolean, - isTransparent?: boolean -} - -export function Underlay({isOpen, isTransparent, ...otherProps}: UnderlayProps): JSX.Element { - let pageHeight: number | undefined = undefined; - if (typeof document !== 'undefined') { - let scrollingElement = isScrollable(document.body) ? document.body : document.scrollingElement || document.documentElement; - // Prevent Firefox from adding scrollbars when the page has a fractional height. - let fractionalHeightDifference = scrollingElement.getBoundingClientRect().height % 1; - pageHeight = scrollingElement.scrollHeight - fractionalHeightDifference; - } - - return ( -
- ); -} diff --git a/packages/@react-spectrum/menu/src/calculatePosition.ts b/packages/@react-spectrum/menu/src/calculatePosition.ts deleted file mode 100644 index ad872c7d7ea..00000000000 --- a/packages/@react-spectrum/menu/src/calculatePosition.ts +++ /dev/null @@ -1,627 +0,0 @@ -/* - * Copyright 2020 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 {Axis, Placement, PlacementAxis, SizeAxis} from '@react-types/overlays'; -import {clamp, isWebKit} from '@react-aria/utils'; - -interface Position { - top?: number, - left?: number, - bottom?: number, - right?: number -} - -interface Dimensions { - width: number, - height: number, - totalWidth: number, - totalHeight: number, - top: number, - left: number, - scroll: Position -} - -interface ParsedPlacement { - placement: PlacementAxis, - crossPlacement: PlacementAxis, - axis: Axis, - crossAxis: Axis, - size: SizeAxis, - crossSize: SizeAxis -} - -interface Offset { - top: number, - left: number, - width: number, - height: number -} - -interface PositionOpts { - arrowSize: number, - placement: Placement, - targetNode: Element, - overlayNode: Element, - scrollNode: Element, - padding: number, - shouldFlip: boolean, - boundaryElement: Element, - offset: number, - crossOffset: number, - maxHeight?: number, - arrowBoundaryOffset?: number -} - -type HeightGrowthDirection = 'top' | 'bottom'; - -export interface PositionResult { - position: Position, - arrowOffsetLeft?: number, - arrowOffsetTop?: number, - triggerAnchorPoint: {x: number, y: number}, - maxHeight: number, - placement: PlacementAxis -} - -const AXIS = { - top: 'top', - bottom: 'top', - left: 'left', - right: 'left' -}; - -const FLIPPED_DIRECTION = { - top: 'bottom', - bottom: 'top', - left: 'right', - right: 'left' -}; - -const CROSS_AXIS = { - top: 'left', - left: 'top' -}; - -const AXIS_SIZE = { - top: 'height', - left: 'width' -}; - -const TOTAL_SIZE = { - width: 'totalWidth', - height: 'totalHeight' -}; - -const PARSED_PLACEMENT_CACHE = {}; - -let visualViewport = typeof document !== 'undefined' ? window.visualViewport : null; - -function getContainerDimensions(containerNode: Element): Dimensions { - let width = 0, height = 0, totalWidth = 0, totalHeight = 0, top = 0, left = 0; - let scroll: Position = {}; - let isPinchZoomedIn = (visualViewport?.scale ?? 1) > 1; - - if (containerNode.tagName === 'BODY') { - let documentElement = document.documentElement; - totalWidth = documentElement.clientWidth; - totalHeight = documentElement.clientHeight; - width = visualViewport?.width ?? totalWidth; - height = visualViewport?.height ?? totalHeight; - scroll.top = documentElement.scrollTop || containerNode.scrollTop; - scroll.left = documentElement.scrollLeft || containerNode.scrollLeft; - - // The goal of the below is to get a top/left value that represents the top/left of the visual viewport with - // respect to the layout viewport origin. This combined with the scrollTop/scrollLeft will allow us to calculate - // coordinates/values with respect to the visual viewport or with respect to the layout viewport. - if (visualViewport) { - top = visualViewport.offsetTop; - left = visualViewport.offsetLeft; - } - } else { - ({width, height, top, left} = getOffset(containerNode, false)); - scroll.top = containerNode.scrollTop; - scroll.left = containerNode.scrollLeft; - totalWidth = width; - totalHeight = height; - } - - if (isWebKit() && (containerNode.tagName === 'BODY' || containerNode.tagName === 'HTML') && isPinchZoomedIn) { - // Safari will report a non-zero scrollTop/Left for the non-scrolling body/HTML element when pinch zoomed in unlike other browsers. - // Set to zero for parity calculations so we get consistent positioning of overlays across all browsers. - // Also switch to visualViewport.pageTop/pageLeft so that we still accomodate for scroll positioning for body/HTML elements that are actually scrollable - // before pinch zoom happens - scroll.top = 0; - scroll.left = 0; - top = visualViewport?.pageTop ?? 0; - left = visualViewport?.pageLeft ?? 0; - } - - return {width, height, totalWidth, totalHeight, scroll, top, left}; -} - -function getScroll(node: Element): Offset { - return { - top: node.scrollTop, - left: node.scrollLeft, - width: node.scrollWidth, - height: node.scrollHeight - }; -} - -// Determines the amount of space required when moving the overlay to ensure it remains in the boundary -function getDelta( - axis: Axis, - offset: number, - size: number, - // The dimensions of the boundary element that the popover is - // positioned within (most of the time this is the ). - boundaryDimensions: Dimensions, - // The dimensions of the containing block element that the popover is - // positioned relative to (e.g. parent with position: relative). - // Usually this is the same as the boundary element, but if the popover - // is portaled somewhere other than the body and has an ancestor with - // position: relative/absolute, it will be different. - containerDimensions: Dimensions, - padding: number, - containerOffsetWithBoundary: Offset -) { - let containerScroll = containerDimensions.scroll[axis] ?? 0; - // The height/width of the boundary. Matches the axis along which we are adjusting the overlay position - let boundarySize = boundaryDimensions[AXIS_SIZE[axis]]; - // Calculate the edges of the boundary (accomodating for the boundary padding) and the edges of the overlay. - // Note that these values are with respect to the visual viewport (aka 0,0 is the top left of the viewport) - let boundaryStartEdge = boundaryDimensions.scroll[AXIS[axis]] + padding; - let boundaryEndEdge = boundarySize + boundaryDimensions.scroll[AXIS[axis]] - padding; - let startEdgeOffset = offset - containerScroll + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; - let endEdgeOffset = offset - containerScroll + size + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; - - // If any of the overlay edges falls outside of the boundary, shift the overlay the required amount to align one of the overlay's - // edges with the closest boundary edge. - if (startEdgeOffset < boundaryStartEdge) { - return boundaryStartEdge - startEdgeOffset; - } else if (endEdgeOffset > boundaryEndEdge) { - return Math.max(boundaryEndEdge - endEdgeOffset, boundaryStartEdge - startEdgeOffset); - } else { - return 0; - } -} - -function getMargins(node: Element): Position { - let style = window.getComputedStyle(node); - return { - top: parseInt(style.marginTop, 10) || 0, - bottom: parseInt(style.marginBottom, 10) || 0, - left: parseInt(style.marginLeft, 10) || 0, - right: parseInt(style.marginRight, 10) || 0 - }; -} - -function parsePlacement(input: Placement): ParsedPlacement { - if (PARSED_PLACEMENT_CACHE[input]) { - return PARSED_PLACEMENT_CACHE[input]; - } - - let [placement, crossPlacement] = input.split(' '); - let axis: Axis = AXIS[placement] || 'right'; - let crossAxis: Axis = CROSS_AXIS[axis]; - - if (!AXIS[crossPlacement]) { - crossPlacement = 'center'; - } - - let size = AXIS_SIZE[axis]; - let crossSize = AXIS_SIZE[crossAxis]; - PARSED_PLACEMENT_CACHE[input] = {placement, crossPlacement, axis, crossAxis, size, crossSize}; - return PARSED_PLACEMENT_CACHE[input]; -} - -function computePosition( - childOffset: Offset, - boundaryDimensions: Dimensions, - overlaySize: Offset, - placementInfo: ParsedPlacement, - offset: number, - crossOffset: number, - containerOffsetWithBoundary: Offset, - isContainerPositioned: boolean, - arrowSize: number, - arrowBoundaryOffset: number -) { - let {placement, crossPlacement, axis, crossAxis, size, crossSize} = placementInfo; - let position: Position = {}; - - // button position - position[crossAxis] = childOffset[crossAxis] ?? 0; - if (crossPlacement === 'center') { - // + (button size / 2) - (overlay size / 2) - // at this point the overlay center should match the button center - position[crossAxis]! += ((childOffset[crossSize] ?? 0) - (overlaySize[crossSize] ?? 0)) / 2; - } else if (crossPlacement !== crossAxis) { - // + (button size) - (overlay size) - // at this point the overlay bottom should match the button bottom - position[crossAxis]! += (childOffset[crossSize] ?? 0) - (overlaySize[crossSize] ?? 0); - }/* else { - the overlay top should match the button top - } */ - - position[crossAxis]! += crossOffset; - - // overlay top overlapping arrow with button bottom - const minPosition = childOffset[crossAxis] - overlaySize[crossSize] + arrowSize + arrowBoundaryOffset; - // overlay bottom overlapping arrow with button top - const maxPosition = childOffset[crossAxis] + childOffset[crossSize] - arrowSize - arrowBoundaryOffset; - position[crossAxis] = clamp(position[crossAxis]!, minPosition, maxPosition); - - // Floor these so the position isn't placed on a partial pixel, only whole pixels. Shouldn't matter if it was floored or ceiled, so chose one. - if (placement === axis) { - // If the container is positioned (non-static), then we use the container's actual - // height, as `bottom` will be relative to this height. But if the container is static, - // then it can only be the `document.body`, and `bottom` will be relative to _its_ - // container, which should be as large as boundaryDimensions. - const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary[size] : boundaryDimensions[TOTAL_SIZE[size]]); - position[FLIPPED_DIRECTION[axis]] = Math.floor(containerHeight - childOffset[axis] + offset); - } else { - position[axis] = Math.floor(childOffset[axis] + childOffset[size] + offset); - } - return position; -} - -function getMaxHeight( - position: Position, - boundaryDimensions: Dimensions, - containerOffsetWithBoundary: Offset, - isContainerPositioned: boolean, - margins: Position, - padding: number, - overlayHeight: number, - heightGrowthDirection: HeightGrowthDirection -) { - const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary.height : boundaryDimensions[TOTAL_SIZE.height]); - // For cases where position is set via "bottom" instead of "top", we need to calculate the true overlay top with respect to the boundary. Reverse calculate this with the same method - // used in computePosition. - let overlayTop = position.top != null ? containerOffsetWithBoundary.top + position.top : containerOffsetWithBoundary.top + (containerHeight - (position.bottom ?? 0) - overlayHeight); - let maxHeight = heightGrowthDirection !== 'top' ? - // We want the distance between the top of the overlay to the bottom of the boundary - Math.max(0, - (boundaryDimensions.height + boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the bottom of the boundary - - overlayTop // this is the top of the overlay - - ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding - ) - // We want the distance between the bottom of the overlay to the top of the boundary - : Math.max(0, - (overlayTop + overlayHeight) // this is the bottom of the overlay - - (boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the top of the boundary - - ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding - ); - return Math.min(boundaryDimensions.height - (padding * 2), maxHeight); -} - -function getAvailableSpace( - boundaryDimensions: Dimensions, - containerOffsetWithBoundary: Offset, - childOffset: Offset, - margins: Position, - padding: number, - placementInfo: ParsedPlacement -) { - let {placement, axis, size} = placementInfo; - if (placement === axis) { - return Math.max(0, childOffset[axis] - boundaryDimensions[axis] - (boundaryDimensions.scroll[axis] ?? 0) + containerOffsetWithBoundary[axis] - (margins[axis] ?? 0) - margins[FLIPPED_DIRECTION[axis]] - padding); - } - - return Math.max(0, boundaryDimensions[size] + boundaryDimensions[axis] + boundaryDimensions.scroll[axis] - containerOffsetWithBoundary[axis] - childOffset[axis] - childOffset[size] - (margins[axis] ?? 0) - margins[FLIPPED_DIRECTION[axis]] - padding); -} - -export function calculatePositionInternal( - placementInput: Placement, - childOffset: Offset, - overlaySize: Offset, - scrollSize: Offset, - margins: Position, - padding: number, - flip: boolean, - boundaryDimensions: Dimensions, - containerDimensions: Dimensions, - containerOffsetWithBoundary: Offset, - offset: number, - crossOffset: number, - isContainerPositioned: boolean, - userSetMaxHeight: number | undefined, - arrowSize: number, - arrowBoundaryOffset: number -): PositionResult { - let placementInfo = parsePlacement(placementInput); - let {size, crossAxis, crossSize, placement, crossPlacement} = placementInfo; - let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset); - let normalizedOffset = offset; - let space = getAvailableSpace( - boundaryDimensions, - containerOffsetWithBoundary, - childOffset, - margins, - padding + offset, - placementInfo - ); - - // Check if the scroll size of the overlay is greater than the available space to determine if we need to flip - if (flip && scrollSize[size] > space) { - let flippedPlacementInfo = parsePlacement(`${FLIPPED_DIRECTION[placement]} ${crossPlacement}` as Placement); - let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset); - let flippedSpace = getAvailableSpace( - boundaryDimensions, - containerOffsetWithBoundary, - childOffset, - margins, - padding + offset, - flippedPlacementInfo - ); - - // If the available space for the flipped position is greater than the original available space, flip. - if (flippedSpace > space) { - placementInfo = flippedPlacementInfo; - position = flippedPosition; - normalizedOffset = offset; - } - } - - // Determine the direction the height of the overlay can grow so that we can choose how to calculate the max height - let heightGrowthDirection: HeightGrowthDirection = 'bottom'; - if (placementInfo.axis === 'top') { - if (placementInfo.placement === 'top') { - heightGrowthDirection = 'top'; - } else if (placementInfo.placement === 'bottom') { - heightGrowthDirection = 'bottom'; - } - } else if (placementInfo.crossAxis === 'top') { - if (placementInfo.crossPlacement === 'top') { - heightGrowthDirection = 'bottom'; - } else if (placementInfo.crossPlacement === 'bottom') { - heightGrowthDirection = 'top'; - } - } - - let delta = getDelta(crossAxis, position[crossAxis]!, overlaySize[crossSize], boundaryDimensions, containerDimensions, padding, containerOffsetWithBoundary); - position[crossAxis]! += delta; - - let maxHeight = getMaxHeight( - position, - boundaryDimensions, - containerOffsetWithBoundary, - isContainerPositioned, - margins, - padding, - overlaySize.height, - heightGrowthDirection - ); - - if (userSetMaxHeight && userSetMaxHeight < maxHeight) { - maxHeight = userSetMaxHeight; - } - - overlaySize.height = Math.min(overlaySize.height, maxHeight); - - position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset); - delta = getDelta(crossAxis, position[crossAxis]!, overlaySize[crossSize], boundaryDimensions, containerDimensions, padding, containerOffsetWithBoundary); - position[crossAxis]! += delta; - - let arrowPosition: Position = {}; - - // All values are transformed so that 0 is at the top/left of the overlay depending on the orientation - // Prefer the arrow being in the center of the trigger/overlay anchor element - // childOffset[crossAxis] + .5 * childOffset[crossSize] = absolute position with respect to the trigger's coordinate system that would place the arrow in the center of the trigger - // position[crossAxis] - margins[AXIS[crossAxis]] = value use to transform the position to a value with respect to the overlay's coordinate system. A child element's (aka arrow) position absolute's "0" - // is positioned after the margin of its parent (aka overlay) so we need to subtract it to get the proper coordinate transform - let origin = childOffset[crossAxis] - position[crossAxis]! - margins[AXIS[crossAxis]]; - let preferredArrowPosition = origin + .5 * childOffset[crossSize]; - - // Min/Max position limits for the arrow with respect to the overlay - const arrowMinPosition = arrowSize / 2 + arrowBoundaryOffset; - // overlaySize[crossSize] - margins = true size of the overlay - const overlayMargin = AXIS[crossAxis] === 'left' ? (margins.left ?? 0) + (margins.right ?? 0) : (margins.top ?? 0) + (margins.bottom ?? 0); - const arrowMaxPosition = overlaySize[crossSize] - overlayMargin - (arrowSize / 2) - arrowBoundaryOffset; - - // Min/Max position limits for the arrow with respect to the trigger/overlay anchor element - // Same margin accomodation done here as well as for the preferredArrowPosition - const arrowOverlappingChildMinEdge = childOffset[crossAxis] + (arrowSize / 2) - (position[crossAxis] + margins[AXIS[crossAxis]]); - const arrowOverlappingChildMaxEdge = childOffset[crossAxis] + childOffset[crossSize] - (arrowSize / 2) - (position[crossAxis] + margins[AXIS[crossAxis]]); - - // Clamp the arrow positioning so that it always is within the bounds of the anchor and the overlay - const arrowPositionOverlappingChild = clamp(preferredArrowPosition, arrowOverlappingChildMinEdge, arrowOverlappingChildMaxEdge); - arrowPosition[crossAxis] = clamp(arrowPositionOverlappingChild, arrowMinPosition, arrowMaxPosition); - - // If there is an arrow, use that as the origin so that animations are smooth. - // Otherwise use the target edge. - ({placement, crossPlacement} = placementInfo); - if (arrowSize) { - origin = arrowPosition[crossAxis]; - } else if (crossPlacement === 'right') { - origin += childOffset[crossSize]; - } else if (crossPlacement === 'center') { - origin += childOffset[crossSize] / 2; - } - - let crossOrigin = placement === 'left' || placement === 'top' ? overlaySize[size] : 0; - let triggerAnchorPoint = { - x: placement === 'top' || placement === 'bottom' ? origin : crossOrigin, - y: placement === 'left' || placement === 'right' ? origin : crossOrigin - }; - - return { - position, - maxHeight: maxHeight, - arrowOffsetLeft: arrowPosition.left, - arrowOffsetTop: arrowPosition.top, - placement, - triggerAnchorPoint - }; -} - -/** - * Determines where to place the overlay with regards to the target and the position of an optional indicator. - */ -export function calculatePosition(opts: PositionOpts): PositionResult { - let { - placement, - targetNode, - overlayNode, - scrollNode, - padding, - shouldFlip, - boundaryElement, - offset, - crossOffset, - maxHeight, - arrowSize = 0, - arrowBoundaryOffset = 0 - } = opts; - - let container = overlayNode instanceof HTMLElement ? getContainingBlock(overlayNode) : document.documentElement; - let isViewportContainer = container === document.documentElement; - const containerPositionStyle = window.getComputedStyle(container).position; - let isContainerPositioned = !!containerPositionStyle && containerPositionStyle !== 'static'; - let childOffset: Offset = isViewportContainer ? getOffset(targetNode, false) : getPosition(targetNode, container, false); - - if (!isViewportContainer) { - let {marginTop, marginLeft} = window.getComputedStyle(targetNode); - childOffset.top += parseInt(marginTop, 10) || 0; - childOffset.left += parseInt(marginLeft, 10) || 0; - } - - let overlaySize: Offset = getOffset(overlayNode, true); - let margins = getMargins(overlayNode); - overlaySize.width += (margins.left ?? 0) + (margins.right ?? 0); - overlaySize.height += (margins.top ?? 0) + (margins.bottom ?? 0); - - let scrollSize = getScroll(scrollNode); - let boundaryDimensions = getContainerDimensions(boundaryElement); - let containerDimensions = getContainerDimensions(container); - // If the container is the HTML element wrapping the body element, the retrieved scrollTop/scrollLeft will be equal to the - // body element's scroll. Set the container's scroll values to 0 since the overlay's edge position value in getDelta don't then need to be further offset - // by the container scroll since they are essentially the same containing element and thus in the same coordinate system - let containerOffsetWithBoundary: Offset = boundaryElement.tagName === 'BODY' ? getOffset(container, false) : getPosition(container, boundaryElement, false); - if (container.tagName === 'HTML' && boundaryElement.tagName === 'BODY') { - containerDimensions.scroll.top = 0; - containerDimensions.scroll.left = 0; - } - - return calculatePositionInternal( - placement, - childOffset, - overlaySize, - scrollSize, - margins, - padding, - shouldFlip, - boundaryDimensions, - containerDimensions, - containerOffsetWithBoundary, - offset, - crossOffset, - isContainerPositioned, - maxHeight, - arrowSize, - arrowBoundaryOffset - ); -} - -export function getRect(node: Element, ignoreScale: boolean) { - let {top, left, width, height} = node.getBoundingClientRect(); - - // Use offsetWidth and offsetHeight if this is an HTML element, so that - // the size is not affected by scale transforms. - if (ignoreScale && node instanceof node.ownerDocument.defaultView!.HTMLElement) { - width = node.offsetWidth; - height = node.offsetHeight; - } - - return {top, left, width, height}; -} - -function getOffset(node: Element, ignoreScale: boolean): Offset { - let {top, left, width, height} = getRect(node, ignoreScale); - let {scrollTop, scrollLeft, clientTop, clientLeft} = document.documentElement; - return { - top: top + scrollTop - clientTop, - left: left + scrollLeft - clientLeft, - width, - height - }; -} - -function getPosition(node: Element, parent: Element, ignoreScale: boolean): Offset { - let style = window.getComputedStyle(node); - let offset: Offset; - if (style.position === 'fixed') { - offset = getRect(node, ignoreScale); - } else { - offset = getOffset(node, ignoreScale); - let parentOffset = getOffset(parent, ignoreScale); - let parentStyle = window.getComputedStyle(parent); - parentOffset.top += (parseInt(parentStyle.borderTopWidth, 10) || 0) - parent.scrollTop; - parentOffset.left += (parseInt(parentStyle.borderLeftWidth, 10) || 0) - parent.scrollLeft; - offset.top -= parentOffset.top; - offset.left -= parentOffset.left; - } - - offset.top -= parseInt(style.marginTop, 10) || 0; - offset.left -= parseInt(style.marginLeft, 10) || 0; - return offset; -} - -// Returns the containing block of an element, which is the element that -// this element will be positioned relative to. -// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block -function getContainingBlock(node: HTMLElement): Element { - // The offsetParent of an element in most cases equals the containing block. - // https://w3c.github.io/csswg-drafts/cssom-view/#dom-htmlelement-offsetparent - let offsetParent = node.offsetParent; - - // The offsetParent algorithm terminates at the document body, - // even if the body is not a containing block. Double check that - // and use the documentElement if so. - if ( - offsetParent && - offsetParent === document.body && - window.getComputedStyle(offsetParent).position === 'static' && - !isContainingBlock(offsetParent) - ) { - offsetParent = document.documentElement; - } - - // TODO(later): handle table elements? - - // The offsetParent can be null if the element has position: fixed, or a few other cases. - // We have to walk up the tree manually in this case because fixed positioned elements - // are still positioned relative to their containing block, which is not always the viewport. - if (offsetParent == null) { - offsetParent = node.parentElement; - while (offsetParent && !isContainingBlock(offsetParent)) { - offsetParent = offsetParent.parentElement; - } - } - - // Fall back to the viewport. - return offsetParent || document.documentElement; -} - -// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block -function isContainingBlock(node: Element): boolean { - let style = window.getComputedStyle(node); - return ( - style.transform !== 'none' || - /transform|perspective/.test(style.willChange) || - style.filter !== 'none' || - style.contain === 'paint' || - ('backdropFilter' in style && style.backdropFilter !== 'none') || - ('WebkitBackdropFilter' in style && style.WebkitBackdropFilter !== 'none') - ); -} diff --git a/packages/@react-spectrum/menu/src/useOverlayPosition.ts b/packages/@react-spectrum/menu/src/useOverlayPosition.ts deleted file mode 100644 index ee218a5b7ca..00000000000 --- a/packages/@react-spectrum/menu/src/useOverlayPosition.ts +++ /dev/null @@ -1,327 +0,0 @@ -/* - * Copyright 2020 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 {calculatePosition, getRect, PositionResult} from './calculatePosition'; -import {DOMAttributes, RefObject} from '@react-types/shared'; -import {nodeContains, 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'; -import {useLocale} from '@react-aria/i18n'; - -export interface AriaPositionProps extends PositionProps { - /** - * Cross size of the overlay arrow in pixels. - * @default 0 - */ - arrowSize?: number, - /** - * Element that that serves as the positioning boundary. - * @default document.body - */ - boundaryElement?: Element, - /** - * The ref for the element which the overlay positions itself with respect to. - */ - targetRef: RefObject, - /** - * The ref for the overlay element. - */ - overlayRef: RefObject, - /** - * The ref for the arrow element. - */ - arrowRef?: RefObject, - /** - * A ref for the scrollable region within the overlay. - * @default overlayRef - */ - scrollRef?: RefObject, - /** - * Whether the overlay should update its position automatically. - * @default true - */ - shouldUpdatePosition?: boolean, - /** Handler that is called when the overlay should close. */ - onClose?: (() => void) | null, - /** - * The maxHeight specified for the overlay element. - * By default, it will take all space up to the current viewport height. - */ - maxHeight?: number, - /** - * The minimum distance the arrow's edge should be from the edge of the overlay element. - * @default 0 - */ - arrowBoundaryOffset?: number -} - -export interface PositionAria { - /** Props for the overlay container element. */ - overlayProps: DOMAttributes, - /** Props for the overlay tip arrow if any. */ - arrowProps: DOMAttributes, - /** Placement of the overlay with respect to the overlay trigger. */ - placement: PlacementAxis | null, - /** The origin of the target in the overlay's coordinate system. Useful for animations. */ - triggerAnchorPoint: {x: number, y: number} | null, - /** Updates the position of the overlay. */ - updatePosition(): void -} - -interface ScrollAnchor { - type: 'top' | 'bottom', - offset: number -} - -let visualViewport = typeof document !== 'undefined' ? window.visualViewport : null; - -/** - * Handles positioning overlays like popovers and menus relative to a trigger - * element, and updating the position when the window resizes. - */ -export function useOverlayPosition(props: AriaPositionProps): PositionAria { - let {direction} = useLocale(); - let { - arrowSize, - targetRef, - overlayRef, - arrowRef, - scrollRef = overlayRef, - placement = 'bottom' as Placement, - containerPadding = 12, - shouldFlip = true, - boundaryElement = typeof document !== 'undefined' ? document.body : null, - offset = 0, - crossOffset = 0, - shouldUpdatePosition = true, - isOpen = true, - onClose, - maxHeight, - arrowBoundaryOffset = 0 - } = props; - let [position, setPosition] = useState(null); - - let deps = [ - shouldUpdatePosition, - placement, - overlayRef.current, - targetRef.current, - arrowRef?.current, - scrollRef.current, - containerPadding, - shouldFlip, - boundaryElement, - offset, - crossOffset, - isOpen, - direction, - maxHeight, - arrowBoundaryOffset, - arrowSize - ]; - - // Note, the position freezing breaks if body sizes itself dynamicly with the visual viewport but that might - // just be a non-realistic use case - // Upon opening a overlay, record the current visual viewport scale so we can freeze the overlay styles - let lastScale = useRef(visualViewport?.scale); - useEffect(() => { - if (isOpen) { - lastScale.current = visualViewport?.scale; - } - }, [isOpen]); - - let updatePosition = useCallback(() => { - if (shouldUpdatePosition === false || !isOpen || !overlayRef.current || !targetRef.current || !boundaryElement) { - return; - } - - if (visualViewport?.scale !== lastScale.current) { - return; - } - - // Determine a scroll anchor based on the focused element. - // This stores the offset of the anchor element from the scroll container - // 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)) { - 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, - // otherwise anchor from the bottom. - anchor = { - type: 'top', - offset: (anchorRect?.top ?? 0) - scrollRect.top - }; - if (anchor.offset > scrollRect.height / 2) { - anchor.type = 'bottom'; - anchor.offset = (anchorRect?.bottom ?? 0) - scrollRect.bottom; - } - } - - // Always reset the overlay's previous max height if not defined by the user so that we can compensate for - // RAC collections populating after a second render and properly set a correct max height + positioning when it populates. - let overlay = (overlayRef.current as HTMLElement); - if (!maxHeight && overlayRef.current) { - overlay.style.top = '0px'; - overlay.style.bottom = ''; - overlay.style.maxHeight = (window.visualViewport?.height ?? window.innerHeight) + 'px'; - } - - let position = calculatePosition({ - placement: translateRTL(placement, direction), - overlayNode: overlayRef.current, - targetNode: targetRef.current, - scrollNode: scrollRef.current || overlayRef.current, - padding: containerPadding, - shouldFlip, - boundaryElement, - offset, - crossOffset, - maxHeight, - arrowSize: arrowSize ?? (arrowRef?.current ? getRect(arrowRef.current, true).width : 0), - arrowBoundaryOffset - }); - - if (!position.position) { - return; - } - - // Modify overlay styles directly so positioning happens immediately without the need of a second render - // This is so we don't have to delay autoFocus scrolling or delay applying preventScroll for popovers - overlay.style.top = ''; - overlay.style.bottom = ''; - overlay.style.left = ''; - overlay.style.right = ''; - - Object.keys(position.position).forEach(key => overlay.style[key] = (position.position!)[key] + 'px'); - overlay.style.maxHeight = position.maxHeight != null ? position.maxHeight + 'px' : ''; - - // Restore scroll position relative to anchor element. - if (anchor && document.activeElement && scrollRef.current) { - let anchorRect = document.activeElement.getBoundingClientRect(); - let scrollRect = scrollRef.current.getBoundingClientRect(); - let newOffset = anchorRect[anchor.type] - scrollRect[anchor.type]; - scrollRef.current.scrollTop += newOffset - anchor.offset; - } - - // Trigger a set state for a second render anyway for arrow positioning - setPosition(position); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps); - - // Update position when anything changes - // eslint-disable-next-line react-hooks/exhaustive-deps - useLayoutEffect(updatePosition, deps); - - // Update position on window resize - useResize(updatePosition); - - // Update position when the overlay changes size (might need to flip). - useResizeObserver({ - ref: overlayRef, - onResize: updatePosition - }); - - // Update position when the target changes size (might need to flip). - useResizeObserver({ - ref: targetRef, - onResize: updatePosition - }); - - // Reposition the overlay and do not close on scroll while the visual viewport is resizing. - // This will ensure that overlays adjust their positioning when the iOS virtual keyboard appears. - let isResizing = useRef(false); - useLayoutEffect(() => { - let timeout: ReturnType; - let onResize = () => { - isResizing.current = true; - clearTimeout(timeout); - - timeout = setTimeout(() => { - isResizing.current = false; - }, 500); - - updatePosition(); - }; - - // Only reposition the overlay if a scroll event happens immediately as a result of resize (aka the virtual keyboard has appears) - // We don't want to reposition the overlay if the user has pinch zoomed in and is scrolling the viewport around. - let onScroll = () => { - if (isResizing.current) { - onResize(); - } - }; - - visualViewport?.addEventListener('resize', onResize); - visualViewport?.addEventListener('scroll', onScroll); - return () => { - visualViewport?.removeEventListener('resize', onResize); - visualViewport?.removeEventListener('scroll', onScroll); - }; - }, [updatePosition]); - - let close = useCallback(() => { - if (!isResizing.current) { - onClose?.(); - } - }, [onClose, isResizing]); - - // When scrolling a parent scrollable region of the trigger (other than the body), - // we hide the popover. Otherwise, its position would be incorrect. - useCloseOnScroll({ - triggerRef: targetRef, - isOpen, - onClose: onClose && close - }); - - return { - overlayProps: { - style: { - position: position ? 'absolute' : 'fixed', - top: !position ? 0 : undefined, - left: !position ? 0 : undefined, - zIndex: 100000, // should match the z-index in ModalTrigger - ...position?.position, - maxHeight: position?.maxHeight ?? '100vh' - } - }, - placement: position?.placement ?? null, - triggerAnchorPoint: position?.triggerAnchorPoint ?? null, - arrowProps: { - 'aria-hidden': 'true', - role: 'presentation', - style: { - left: position?.arrowOffsetLeft, - top: position?.arrowOffsetTop - } - }, - updatePosition - }; -} - -function useResize(onResize) { - useLayoutEffect(() => { - window.addEventListener('resize', onResize, false); - return () => { - window.removeEventListener('resize', onResize, false); - }; - }, [onResize]); -} - -function translateRTL(position, direction) { - if (direction === 'rtl') { - return position.replace('start', 'right').replace('end', 'left'); - } - return position.replace('start', 'left').replace('end', 'right'); -} diff --git a/packages/@react-spectrum/menu/src/usePopover.ts b/packages/@react-spectrum/menu/src/usePopover.ts deleted file mode 100644 index 2016a4d7da6..00000000000 --- a/packages/@react-spectrum/menu/src/usePopover.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright 2022 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 {ariaHideOutside, AriaPositionProps, useOverlay, usePreventScroll} from '@react-aria/overlays'; -import {DOMAttributes, RefObject} from '@react-types/shared'; -import {mergeProps} from '@react-aria/utils'; -import {OverlayTriggerState} from '@react-stately/overlays'; -import {PlacementAxis} from '@react-types/overlays'; -import {useEffect} from 'react'; -import {useOverlayPosition} from './useOverlayPosition'; - -export interface AriaPopoverProps extends Omit { - /** - * The ref for the element which the popover positions itself with respect to. - */ - triggerRef: RefObject, - /** - * The ref for the popover element. - */ - popoverRef: RefObject, - /** A ref for the popover arrow element. */ - arrowRef?: RefObject, - /** - * An optional ref for a group of popovers, e.g. submenus. - * When provided, this element is used to detect outside interactions - * and hiding elements from assistive technologies instead of the popoverRef. - */ - groupRef?: RefObject, - /** - * Whether the popover is non-modal, i.e. elements outside the popover may be - * interacted with by assistive technologies. - * - * Most popovers should not use this option as it may negatively impact the screen - * reader experience. Only use with components such as combobox, which are designed - * to handle this situation carefully. - */ - isNonModal?: boolean, - /** - * Whether pressing the escape key to close the popover should be disabled. - * - * Most popovers should not use this option. When set to true, an alternative - * way to close the popover with a keyboard must be provided. - * - * @default false - */ - isKeyboardDismissDisabled?: boolean, - /** - * When user interacts with the argument element outside of the popover ref, - * return true if onClose should be called. This gives you a chance to filter - * out interaction with elements that should not dismiss the popover. - * By default, onClose will always be called on interaction outside the popover ref. - */ - shouldCloseOnInteractOutside?: (element: Element) => boolean -} - -export interface PopoverAria { - /** Props for the popover element. */ - popoverProps: DOMAttributes, - /** Props for the popover tip arrow if any. */ - arrowProps: DOMAttributes, - /** Props to apply to the underlay element, if any. */ - underlayProps: DOMAttributes, - /** Placement of the popover with respect to the trigger. */ - placement: PlacementAxis | null, - /** The origin of the target in the overlay's coordinate system. Useful for animations. */ - triggerAnchorPoint: {x: number, y: number} | null -} - -/** - * Provides the behavior and accessibility implementation for a popover component. - * A popover is an overlay element positioned relative to a trigger. - */ -export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState): PopoverAria { - let { - triggerRef, - popoverRef, - groupRef, - isNonModal, - isKeyboardDismissDisabled, - shouldCloseOnInteractOutside, - ...otherProps - } = props; - - let isSubmenu = otherProps['trigger'] === 'SubmenuTrigger'; - - let {overlayProps, underlayProps} = useOverlay( - { - isOpen: state.isOpen, - onClose: state.close, - shouldCloseOnBlur: true, - isDismissable: !isNonModal || isSubmenu, - isKeyboardDismissDisabled, - shouldCloseOnInteractOutside - }, - groupRef ?? popoverRef - ); - - let {overlayProps: positionProps, arrowProps, placement, triggerAnchorPoint: origin} = useOverlayPosition({ - ...otherProps, - targetRef: triggerRef, - overlayRef: popoverRef, - isOpen: state.isOpen, - onClose: isNonModal && !isSubmenu ? state.close : null - }); - - usePreventScroll({ - isDisabled: isNonModal || !state.isOpen - }); - - useEffect(() => { - if (state.isOpen && popoverRef.current) { - if (isNonModal) { - return; - } else { - return ariaHideOutside([groupRef?.current ?? popoverRef.current], {shouldUseInert: true}); - } - } - }, [isNonModal, state.isOpen, popoverRef, groupRef]); - - return { - popoverProps: mergeProps(overlayProps, positionProps), - arrowProps, - underlayProps, - placement, - triggerAnchorPoint: origin - }; -} diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index df243e855c7..5a2a6dbd450 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 @@ -1302,7 +1302,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]);