diff --git a/eslint.config.mjs b/eslint.config.mjs index 6396de82bc3..809ac66aef2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -249,6 +249,7 @@ export default [{ "rsp-rules/no-react-key": [ERROR], "rsp-rules/sort-imports": [ERROR], + "rsp-rules/no-non-shadow-contains": [ERROR], "rulesdir/imports": [ERROR], "rulesdir/useLayoutEffectRule": [ERROR], "rulesdir/pure-render": [ERROR], @@ -428,6 +429,7 @@ export default [{ "rsp-rules/no-react-key": [ERROR], "rsp-rules/act-events-test": ERROR, "rsp-rules/no-getByRole-toThrow": ERROR, + "rsp-rules/no-non-shadow-contains": OFF, "rulesdir/imports": OFF, "monorepo/no-internal-import": OFF, "jsdoc/require-jsdoc": OFF diff --git a/packages/@react-aria/actiongroup/src/useActionGroup.ts b/packages/@react-aria/actiongroup/src/useActionGroup.ts index 336e32ac79d..c2afb86c9a7 100644 --- a/packages/@react-aria/actiongroup/src/useActionGroup.ts +++ b/packages/@react-aria/actiongroup/src/useActionGroup.ts @@ -13,7 +13,7 @@ import {AriaActionGroupProps} from '@react-types/actiongroup'; import {createFocusManager} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, Orientation, RefObject} from '@react-types/shared'; -import {filterDOMProps, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, nodeContains, useLayoutEffect} from '@react-aria/utils'; import {ListState} from '@react-stately/list'; import {useLocale} from '@react-aria/i18n'; import {useState} from 'react'; @@ -48,7 +48,7 @@ export function useActionGroup(props: AriaActionGroupProps, state: ListSta let focusManager = createFocusManager(ref); let flipDirection = direction === 'rtl' && orientation === 'horizontal'; let onKeyDown = (e) => { - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, e.target)) { return; } diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index 87695d4268d..f228c77b477 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -13,8 +13,8 @@ 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 {RangeCalendarState} from '@react-stately/calendar'; -import {useEvent} from '@react-aria/utils'; import {useRef} from 'react'; /** @@ -52,8 +52,8 @@ export function useRangeCalendar(props: AriaRangeCalendarPr let target = e.target as Element; if ( ref.current && - ref.current.contains(document.activeElement) && - (!ref.current.contains(target) || !target.closest('button, [role="button"]')) + nodeContains(ref.current, document.activeElement) && + (!nodeContains(ref.current, target) || !target.closest('button, [role="button"]')) ) { state.selectFocusedDate(); } @@ -66,7 +66,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr if (!ref.current) { return; } - if ((!e.relatedTarget || !ref.current.contains(e.relatedTarget)) && state.anchorDate) { + if ((!e.relatedTarget || !nodeContains(ref.current, e.relatedTarget)) && state.anchorDate) { state.selectFocusedDate(); } }; diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index f6c6c9ba03c..c8db04c19cc 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -16,7 +16,7 @@ import {AriaComboBoxProps} from '@react-types/combobox'; import {ariaHideOutside} from '@react-aria/overlays'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; -import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, useEvent, useFormReset, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; +import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useFormReset, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; import {ComboBoxState} from '@react-stately/combobox'; import {dispatchVirtualFocus} from '@react-aria/focus'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react'; @@ -181,7 +181,7 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta let onBlur = (e: FocusEvent) => { let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; - let blurIntoPopover = popoverRef.current?.contains(e.relatedTarget); + let blurIntoPopover = nodeContains(popoverRef.current, e.relatedTarget); // Ignore blur if focused moved to the button(if exists) or into the popover. if (blurFromButton || blurIntoPopover) { return; diff --git a/packages/@react-aria/datepicker/src/useDatePicker.ts b/packages/@react-aria/datepicker/src/useDatePicker.ts index f768b34df88..2677b39c020 100644 --- a/packages/@react-aria/datepicker/src/useDatePicker.ts +++ b/packages/@react-aria/datepicker/src/useDatePicker.ts @@ -17,7 +17,7 @@ import {CalendarProps} from '@react-types/calendar'; import {createFocusManager} from '@react-aria/focus'; import {DatePickerState} from '@react-stately/datepicker'; import {DOMAttributes, GroupDOMAttributes, KeyboardEvent, RefObject, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useDescription, useId} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import {privateValidationStateProp} from '@react-stately/form'; @@ -84,7 +84,7 @@ export function useDatePicker(props: AriaDatePickerProps onBlurWithin: e => { // Ignore when focus moves into the popover. let dialog = document.getElementById(dialogId); - if (!dialog?.contains(e.relatedTarget)) { + if (!nodeContains(dialog, e.relatedTarget)) { isFocused.current = false; props.onBlur?.(e); props.onFocusChange?.(false); diff --git a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts index 862ba7e08a0..b8431acc549 100644 --- a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts +++ b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts @@ -1,7 +1,7 @@ import {createFocusManager, getFocusableTreeWalker} from '@react-aria/focus'; import {DateFieldState, DatePickerState, DateRangePickerState} from '@react-stately/datepicker'; import {DOMAttributes, FocusableElement, KeyboardEvent, RefObject} from '@react-types/shared'; -import {mergeProps} from '@react-aria/utils'; +import {mergeProps, nodeContains} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; import {useMemo} from 'react'; import {usePress} from '@react-aria/interactions'; @@ -12,7 +12,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState // Open the popover on alt + arrow down let onKeyDown = (e: KeyboardEvent) => { - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, e.target as Element)) { return; } diff --git a/packages/@react-aria/datepicker/src/useDateRangePicker.ts b/packages/@react-aria/datepicker/src/useDateRangePicker.ts index 6e9c748455b..5614487fced 100644 --- a/packages/@react-aria/datepicker/src/useDateRangePicker.ts +++ b/packages/@react-aria/datepicker/src/useDateRangePicker.ts @@ -18,7 +18,7 @@ import {DateRange, RangeCalendarProps} from '@react-types/calendar'; import {DateRangePickerState} from '@react-stately/datepicker'; import {DEFAULT_VALIDATION_RESULT, mergeValidation, privateValidationStateProp} from '@react-stately/form'; import {DOMAttributes, GroupDOMAttributes, KeyboardEvent, RefObject, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useDescription, useId} from '@react-aria/utils'; import {focusManagerSymbol, roleSymbol} from './useDateField'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -116,7 +116,7 @@ export function useDateRangePicker(props: AriaDateRangePick onBlurWithin: e => { // Ignore when focus moves into the popover. let dialog = document.getElementById(dialogId); - if (!dialog?.contains(e.relatedTarget)) { + if (!nodeContains(dialog, e.relatedTarget)) { isFocused.current = false; props.onBlur?.(e); props.onFocusChange?.(false); diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index 2aad84bc0ea..328ff68c6e1 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -12,7 +12,7 @@ import {CalendarDate, toCalendar} from '@internationalized/date'; import {DateFieldState, DateSegment} from '@react-stately/datepicker'; -import {getScrollParent, isIOS, isMac, mergeProps, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils'; +import {getScrollParent, isIOS, isMac, mergeProps, nodeContains, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils'; import {hookData} from './useDateField'; import {NumberParser} from '@internationalized/number'; import React, {CSSProperties, useMemo, useRef} from 'react'; @@ -281,7 +281,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: // Otherwise, when tapping on a segment in Android Chrome and then entering text, // composition events will be fired that break the DOM structure and crash the page. let selection = window.getSelection(); - if (selection?.anchorNode && ref.current?.contains(selection?.anchorNode)) { + if (selection?.anchorNode && nodeContains(ref.current, selection?.anchorNode)) { selection.collapse(ref.current); } }); diff --git a/packages/@react-aria/dialog/src/useDialog.ts b/packages/@react-aria/dialog/src/useDialog.ts index 33c4a144b5a..eef23f9968c 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, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, nodeContains, 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 && !ref.current.contains(document.activeElement)) { + if (ref.current && !nodeContains(ref.current, document.activeElement)) { focusSafely(ref.current); // Safari on iOS does not move the VoiceOver cursor to the dialog diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts index 2128797ffb4..0a4be7afba1 100644 --- a/packages/@react-aria/dnd/src/DragManager.ts +++ b/packages/@react-aria/dnd/src/DragManager.ts @@ -14,7 +14,7 @@ import {announce} from '@react-aria/live-announcer'; import {ariaHideOutside} from '@react-aria/overlays'; import {DragEndEvent, DragItem, DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropItem, DropOperation, DropTarget as DroppableCollectionTarget, FocusableElement} from '@react-types/shared'; import {getDragModality, getTypes} from './utils'; -import {isVirtualClick, isVirtualPointerEvent} from '@react-aria/utils'; +import {isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils'; import type {LocalizedStringFormatter} from '@internationalized/string'; import {RefObject, useEffect, useState} from 'react'; @@ -114,7 +114,7 @@ function endDragging() { export function isValidDropTarget(element: Element): boolean { for (let target of dropTargets.keys()) { - if (target.contains(element)) { + if (nodeContains(target, element)) { return true; } } @@ -243,7 +243,7 @@ class DragSession { this.cancelEvent(e); if (e.key === 'Enter') { - if (e.altKey || this.getCurrentActivateButton()?.contains(e.target as Node)) { + if (e.altKey || nodeContains(this.getCurrentActivateButton(), e.target as Node)) { this.activate(this.currentDropTarget, this.currentDropItem); } else { this.drop(); @@ -275,7 +275,7 @@ class DragSession { let dropTarget = this.validDropTargets.find(target => target.element === e.target as HTMLElement) || - this.validDropTargets.find(target => target.element.contains(e.target as HTMLElement)); + this.validDropTargets.find(target => nodeContains(target.element, e.target as HTMLElement)); if (!dropTarget) { // if (e.target === activateButton) { @@ -321,10 +321,10 @@ class DragSession { this.cancelEvent(e); if (isVirtualClick(e) || this.isVirtualClick) { let dropElements = dropItems.values(); - let item = [...dropElements].find(item => item.element === e.target as HTMLElement || item.activateButtonRef?.current?.contains(e.target as HTMLElement)); - let dropTarget = this.validDropTargets.find(target => target.element.contains(e.target as HTMLElement)); + let item = [...dropElements].find(item => item.element === e.target as HTMLElement || nodeContains(item.activateButtonRef?.current, e.target as HTMLElement)); + let dropTarget = this.validDropTargets.find(target => nodeContains(target.element, e.target as HTMLElement)); let activateButton = item?.activateButtonRef?.current ?? dropTarget?.activateButtonRef?.current; - if (activateButton?.contains(e.target as HTMLElement) && dropTarget) { + if (nodeContains(activateButton, e.target as HTMLElement) && dropTarget) { this.activate(dropTarget, item); return; } @@ -401,7 +401,7 @@ class DragSession { // Filter out drop targets that contain valid items. We don't want to stop hiding elements // other than the drop items that exist inside the collection. let visibleDropTargets = this.validDropTargets.filter(target => - !validDropItems.some(item => target.element.contains(item.element)) + !validDropItems.some(item => nodeContains(target.element, item.element)) ); this.restoreAriaHidden = ariaHideOutside([ diff --git a/packages/@react-aria/dnd/src/useDrop.ts b/packages/@react-aria/dnd/src/useDrop.ts index 31b2204546b..03d0fc92f2a 100644 --- a/packages/@react-aria/dnd/src/useDrop.ts +++ b/packages/@react-aria/dnd/src/useDrop.ts @@ -16,7 +16,7 @@ import {DragEvent, useRef, useState} from 'react'; import * as DragManager from './DragManager'; import {DragTypes, globalAllowedDropOperations, globalDndState, readFromDataTransfer, setGlobalDnDState, setGlobalDropEffect} from './utils'; import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, DROP_OPERATION_ALLOWED, DROP_OPERATION_TO_DROP_EFFECT} from './constants'; -import {isIPad, isMac, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {isIPad, isMac, nodeContains, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {useVirtualDrop} from './useVirtualDrop'; export interface DropOptions { @@ -234,7 +234,7 @@ export function useDrop(options: DropOptions): DropResult { state.dragOverElements.delete(e.target as Element); for (let element of state.dragOverElements) { - if (!e.currentTarget.contains(element)) { + if (!nodeContains(e.currentTarget, element)) { state.dragOverElements.delete(element); } } diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 6dad540400a..ab7c4c7f067 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -19,6 +19,7 @@ import { isChrome, isFocusable, isTabbable, + nodeContains, ShadowTreeWalker, useLayoutEffect } from '@react-aria/utils'; @@ -440,7 +441,7 @@ function isElementInScope(element?: Element | null, scope?: Element[] | null) { if (!scope) { return false; } - return scope.some(node => node.contains(element)); + return scope.some(node => nodeContains(node, element)); } function isElementInChildScope(element: Element, scope: ScopeRef = null) { @@ -771,7 +772,7 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions { acceptNode(node) { // Skip nodes inside the starting node. - if (opts?.from?.contains(node)) { + if (nodeContains(opts?.from, node)) { return NodeFilter.FILTER_REJECT; } @@ -822,7 +823,7 @@ export function createFocusManager(ref: RefObject, defaultOption let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; let node = from || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); - if (root.contains(node)) { + if (nodeContains(root, node)) { walker.currentNode = node!; } let nextNode = walker.nextNode() as FocusableElement; @@ -843,7 +844,7 @@ export function createFocusManager(ref: RefObject, defaultOption let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; let node = from || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); - if (root.contains(node)) { + if (nodeContains(root, node)) { walker.currentNode = node!; } else { let next = last(walker); diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 5a8c9935efd..5c6ba84eec0 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -11,7 +11,7 @@ */ import {AriaLabelingProps, DOMAttributes, DOMProps, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useId} from '@react-aria/utils'; import {GridCollection} from '@react-types/grid'; import {GridKeyboardDelegate} from './GridKeyboardDelegate'; import {gridMap} from './utils'; @@ -136,7 +136,7 @@ export function useGrid(props: GridProps, state: GridState { if (manager.isFocused) { // If a focus event bubbled through a portal, reset focus state. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, e.target)) { manager.setFocused(false); } @@ -144,7 +144,7 @@ export function useGrid(props: GridProps, state: GridState>(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 (ref.current.contains(document.activeElement) && ref.current !== document.activeElement) { + if (nodeContains(ref.current, document.activeElement) && ref.current !== document.activeElement) { return; } @@ -90,7 +90,7 @@ export function useGridCell>(props: GridCellProps if ( (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !ref.current.contains(document.activeElement) + !nodeContains(ref.current, document.activeElement) ) { focusSafely(ref.current); } @@ -109,7 +109,7 @@ export function useGridCell>(props: GridCellProps }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!e.currentTarget.contains(e.target as Element) || state.isKeyboardNavigationDisabled || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, e.target as Element) || state.isKeyboardNavigationDisabled || !ref.current || !document.activeElement) { return; } @@ -213,7 +213,7 @@ export function useGridCell>(props: GridCellProps // Prevent this event from reaching cell children, e.g. menu buttons. We want arrow keys to navigate // to the cell above/below instead. We need to re-dispatch the event from a higher parent so it still // bubbles and gets handled by useSelectableCollection. - if (!e.altKey && ref.current.contains(e.target as Element)) { + if (!e.altKey && nodeContains(ref.current, e.target as Element)) { e.stopPropagation(); e.preventDefault(); ref.current.parentElement?.dispatchEvent( diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index f4c32cdfa31..6a1bd8e27c1 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, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; +import {chain, getScrollParent, 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) || - !ref.current?.contains(document.activeElement)) + !nodeContains(ref.current, document.activeElement)) ) { focusSafely(ref.current); } @@ -131,7 +131,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!e.currentTarget.contains(e.target as Element) || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) { return; } @@ -216,7 +216,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt // Prevent this event from reaching row children, e.g. menu buttons. We want arrow keys to navigate // to the row above/below instead. We need to re-dispatch the event from a higher parent so it still // bubbles and gets handled by useSelectableCollection. - if (!e.altKey && ref.current.contains(e.target as Element)) { + if (!e.altKey && nodeContains(ref.current, e.target as Element)) { e.stopPropagation(); e.preventDefault(); ref.current.parentElement?.dispatchEvent( @@ -244,7 +244,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }; let onKeyDown = (e) => { - if (!e.currentTarget.contains(e.target as Element) || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) { return; } diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 9e1c839b612..1faf3127c32 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -54,14 +54,14 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onBlur = useCallback((e: FocusEvent) => { // Ignore events bubbling through portals. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, e.target)) { return; } // We don't want to trigger onBlurWithin and then immediately onFocusWithin again // when moving focus inside the element. Only trigger if the currentTarget doesn't // include the relatedTarget (where focus is moving). - if (state.current.isFocusWithin && !(e.currentTarget as Element).contains(e.relatedTarget as Element)) { + if (state.current.isFocusWithin && !nodeContains(e.currentTarget as Element, e.relatedTarget as Element)) { state.current.isFocusWithin = false; removeAllGlobalListeners(); @@ -78,7 +78,7 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onSyntheticFocus = useSyntheticBlurEvent(onBlur); let onFocus = useCallback((e: FocusEvent) => { // Ignore events bubbling through portals. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, e.target)) { return; } diff --git a/packages/@react-aria/interactions/src/useHover.ts b/packages/@react-aria/interactions/src/useHover.ts index 6c5c69ad0c1..cde3c286128 100644 --- a/packages/@react-aria/interactions/src/useHover.ts +++ b/packages/@react-aria/interactions/src/useHover.ts @@ -108,7 +108,7 @@ export function useHover(props: HoverProps): HoverResult { let {hoverProps, triggerHoverEnd} = useMemo(() => { let triggerHoverStart = (event, pointerType) => { state.pointerType = pointerType; - if (isDisabled || pointerType === 'touch' || state.isHovered || !event.currentTarget.contains(event.target)) { + if (isDisabled || pointerType === 'touch' || state.isHovered || !nodeContains(event.currentTarget, event.target)) { return; } @@ -180,7 +180,7 @@ export function useHover(props: HoverProps): HoverResult { }; hoverProps.onPointerLeave = (e) => { - if (!isDisabled && e.currentTarget.contains(e.target as Element)) { + if (!isDisabled && nodeContains(e.currentTarget, e.target as Element)) { triggerHoverEnd(e, e.pointerType); } }; @@ -198,7 +198,7 @@ export function useHover(props: HoverProps): HoverResult { }; hoverProps.onMouseLeave = (e) => { - if (!isDisabled && e.currentTarget.contains(e.target as Element)) { + if (!isDisabled && nodeContains(e.currentTarget, e.target as Element)) { triggerHoverEnd(e, 'mouse'); } }; diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts index 9f413630ca3..b9580fabc1d 100644 --- a/packages/@react-aria/interactions/src/useInteractOutside.ts +++ b/packages/@react-aria/interactions/src/useInteractOutside.ts @@ -15,7 +15,7 @@ // NOTICE file in the root directory of this source tree. // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions -import {getOwnerDocument, useEffectEvent} from '@react-aria/utils'; +import {getOwnerDocument, nodeContains, useEffectEvent} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect, useRef} from 'react'; @@ -121,7 +121,7 @@ function isValidEvent(event, ref) { if (event.target) { // if the event target is no longer in the document, ignore const ownerDocument = event.target.ownerDocument; - if (!ownerDocument || !ownerDocument.documentElement.contains(event.target)) { + if (!ownerDocument || !nodeContains(ownerDocument.documentElement, event.target)) { return false; } // If the target is within a top layer element (e.g. toasts), ignore. diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index deea828c2a3..d3cca266f43 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -453,7 +453,7 @@ export function usePress(props: PressHookProps): PressResult { return; } - if (state.target && state.target.contains(e.target as Element) && state.pointerType != null) { + if (state.target && nodeContains(state.target, e.target as Element) && state.pointerType != null) { // Wait for onClick to fire onPress. This avoids browser issues when the DOM // is mutated between onMouseUp and onClick, and is more compatible with third party libraries. } else { diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts index aea8768c8f0..692d434981b 100644 --- a/packages/@react-aria/landmark/src/useLandmark.ts +++ b/packages/@react-aria/landmark/src/useLandmark.ts @@ -11,8 +11,8 @@ */ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; +import {nodeContains, useLayoutEffect} from '@react-aria/utils'; import {useCallback, useEffect, useState} from 'react'; -import {useLayoutEffect} from '@react-aria/utils'; import {useSyncExternalStore} from 'use-sync-external-store/shim/index.js'; export type AriaLandmarkRole = 'main' | 'region' | 'search' | 'navigation' | 'form' | 'banner' | 'contentinfo' | 'complementary'; @@ -325,7 +325,7 @@ class LandmarkManager implements LandmarkManagerApi { private focusMain() { let main = this.getLandmarkByRole('main'); - if (main && main.ref.current && document.contains(main.ref.current)) { + if (main && main.ref.current && nodeContains(document, main.ref.current)) { this.focusLandmark(main.ref.current, 'forward'); return true; } @@ -345,14 +345,14 @@ class LandmarkManager implements LandmarkManagerApi { // If something was previously focused in the next landmark, then return focus to it if (nextLandmark.lastFocused) { let lastFocused = nextLandmark.lastFocused; - if (document.body.contains(lastFocused)) { + if (nodeContains(document.body, lastFocused)) { lastFocused.focus(); return true; } } // Otherwise, focus the landmark itself - if (nextLandmark.ref.current && document.contains(nextLandmark.ref.current)) { + if (nextLandmark.ref.current && nodeContains(document, nextLandmark.ref.current)) { this.focusLandmark(nextLandmark.ref.current, backward ? 'backward' : 'forward'); return true; } diff --git a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts index f4fe57fb2bd..979d0078eb3 100644 --- a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts +++ b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts @@ -1,7 +1,7 @@ +import {nodeContains, useEffectEvent, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect, useRef, useState} from 'react'; -import {useEffectEvent, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useInteractionModality} from '@react-aria/interactions'; interface SafelyMouseToSubmenuOptions { @@ -148,7 +148,7 @@ export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions): v // Fire a pointerover event to trigger the menu to close. // Wait until pointer-events:none is no longer applied let target = document.elementFromPoint(mouseX, mouseY); - if (target && menu.contains(target)) { + if (target && nodeContains(menu, target)) { target.dispatchEvent(new PointerEvent('pointerover', {bubbles: true, cancelable: true})); } }, 100); diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index eed89771089..14df02c2243 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, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, 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,13 +100,13 @@ 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 (!e.currentTarget.contains(document.activeElement)) { + if (!nodeContains(e.currentTarget, document.activeElement)) { return; } switch (e.key) { case 'ArrowLeft': - if (direction === 'ltr' && e.currentTarget.contains(e.target as Element)) { + if (direction === 'ltr' && nodeContains(e.currentTarget, e.target as Element)) { e.preventDefault(); e.stopPropagation(); onSubmenuClose(); @@ -116,7 +116,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm } break; case 'ArrowRight': - if (direction === 'rtl' && e.currentTarget.contains(e.target as Element)) { + if (direction === 'rtl' && nodeContains(e.currentTarget, e.target as Element)) { e.preventDefault(); e.stopPropagation(); onSubmenuClose(); @@ -127,7 +127,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm break; case 'Escape': // TODO: can remove this when we fix collection event leaks - if (submenuRef.current?.contains(e.target as Element)) { + if (nodeContains(submenuRef.current, e.target as Element)) { e.stopPropagation(); onSubmenuClose(); if (!shouldUseVirtualFocus && ref.current) { @@ -226,7 +226,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm useEvent(parentMenuRef, 'focusin', (e) => { // If we detect focus moved to a different item in the same menu that the currently open submenu trigger is in // then close the submenu. This is for a case where the user hovers a root menu item when multiple submenus are open - if (state.isOpen && (parentMenuRef.current?.contains(e.target as HTMLElement) && e.target !== ref.current)) { + if (state.isOpen && (nodeContains(parentMenuRef.current, e.target as HTMLElement) && e.target !== ref.current)) { onSubmenuClose(); } }); diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 47217ab4f41..763c376ced6 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {getOwnerWindow} from '@react-aria/utils'; +import {getOwnerWindow, nodeContains} from '@react-aria/utils'; const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype; interface AriaHideOutsideOptions { @@ -85,7 +85,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt // Skip this node but continue to children if one of the targets is inside the node. for (let target of visibleNodes) { - if (node.contains(target)) { + if (nodeContains(node, target)) { return NodeFilter.FILTER_SKIP; } } @@ -150,7 +150,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt if ( change.target.isConnected && ![...visibleNodes, ...hiddenNodes].some((node) => - node.contains(change.target) + nodeContains(node, change.target) ) ) { for (let node of change.addedNodes) { diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index e5df4569701..07415fc045e 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -11,7 +11,7 @@ */ import {Axis, Placement, PlacementAxis, SizeAxis} from '@react-types/overlays'; -import {clamp, isWebKit} from '@react-aria/utils'; +import {clamp, isWebKit, nodeContains} from '@react-aria/utils'; interface Position { top?: number, @@ -563,7 +563,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult { // 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); - let isContainerDescendentOfBoundary = boundaryElement.contains(container); + let isContainerDescendentOfBoundary = nodeContains(boundaryElement, container); return calculatePositionInternal( placement, childOffset, diff --git a/packages/@react-aria/overlays/src/useCloseOnScroll.ts b/packages/@react-aria/overlays/src/useCloseOnScroll.ts index 23899dccbf8..64f54860947 100644 --- a/packages/@react-aria/overlays/src/useCloseOnScroll.ts +++ b/packages/@react-aria/overlays/src/useCloseOnScroll.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {nodeContains} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect} from 'react'; @@ -39,7 +40,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { // Ignore if scrolling an scrollable region outside the trigger's tree. let target = e.target; // window is not a Node and doesn't have contain, but window contains everything - if (!triggerRef.current || ((target instanceof Node) && !target.contains(triggerRef.current))) { + if (!triggerRef.current || ((target instanceof Node) && !nodeContains(target, triggerRef.current))) { return; } diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index 59c61a08075..ee218a5b7ca 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -12,10 +12,10 @@ 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 {useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; export interface AriaPositionProps extends PositionProps { @@ -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 && scrollRef.current.contains(document.activeElement)) { + 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, diff --git a/packages/@react-aria/select/src/useSelect.ts b/packages/@react-aria/select/src/useSelect.ts index daebc1d3910..11dc057176e 100644 --- a/packages/@react-aria/select/src/useSelect.ts +++ b/packages/@react-aria/select/src/useSelect.ts @@ -13,7 +13,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaListBoxOptions} from '@react-aria/listbox'; import {AriaSelectProps, SelectionMode} from '@react-types/select'; -import {chain, filterDOMProps, mergeProps, useId} from '@react-aria/utils'; +import {chain, filterDOMProps, mergeProps, nodeContains, useId} from '@react-aria/utils'; import {DOMAttributes, KeyboardDelegate, RefObject, ValidationResult} from '@react-types/shared'; import {FocusEvent, useMemo} from 'react'; import {HiddenSelectProps} from './HiddenSelect'; @@ -223,7 +223,7 @@ export function useSelect(props: AriaSele disallowEmptySelection: true, linkBehavior: 'selection', onBlur: (e) => { - if (e.currentTarget.contains(e.relatedTarget as Node)) { + if (nodeContains(e.currentTarget, e.relatedTarget as Node)) { return; } diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index b747973a80f..39e5dd7fe45 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, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, 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'; @@ -133,7 +133,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // Keyboard events bubble through portals. Don't handle keyboard events // for elements outside the collection (e.g. menus). - if (!ref.current?.contains(e.target as Element)) { + if (!ref.current || !nodeContains(ref.current, e.target as Element)) { return; } @@ -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 && (!next.contains(document.activeElement) || (document.activeElement && !isTabbable(document.activeElement)))) { + if (next && (!nodeContains(next, document.activeElement) || (document.activeElement && !isTabbable(document.activeElement)))) { focusWithoutScrolling(next); } } @@ -337,7 +337,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let onFocus = (e: FocusEvent) => { if (manager.isFocused) { // If a focus event bubbled through a portal, reset focus state. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, e.target)) { manager.setFocused(false); } @@ -345,7 +345,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } // Focus events can bubble through portals. Ignore these events. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, e.target)) { return; } @@ -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 (!element.contains(document.activeElement) && !shouldUseVirtualFocus) { + if (!nodeContains(element, document.activeElement) && !shouldUseVirtualFocus) { focusWithoutScrolling(element); } @@ -393,7 +393,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let onBlur = (e) => { // Don't set blurred and then focused again if moving focus within the collection. - if (!e.currentTarget.contains(e.relatedTarget as HTMLElement)) { + if (!nodeContains(e.currentTarget, e.relatedTarget as HTMLElement)) { manager.setFocused(false); } }; diff --git a/packages/@react-aria/selection/src/useTypeSelect.ts b/packages/@react-aria/selection/src/useTypeSelect.ts index 6a3e7dd7031..2be01de8b3b 100644 --- a/packages/@react-aria/selection/src/useTypeSelect.ts +++ b/packages/@react-aria/selection/src/useTypeSelect.ts @@ -13,6 +13,7 @@ import {DOMAttributes, Key, KeyboardDelegate} from '@react-types/shared'; import {KeyboardEvent, useRef} from 'react'; import {MultipleSelectionManager} from '@react-stately/selection'; +import {nodeContains} from '@react-aria/utils'; /** * Controls how long to wait before clearing the typeahead buffer. @@ -53,7 +54,7 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { let onKeyDown = (e: KeyboardEvent) => { let character = getStringForKey(e.key); - if (!character || e.ctrlKey || e.metaKey || !e.currentTarget.contains(e.target as HTMLElement) || (state.search.length === 0 && character === ' ')) { + if (!character || e.ctrlKey || e.metaKey || !nodeContains(e.currentTarget, e.target as HTMLElement) || (state.search.length === 0 && character === ' ')) { return; } diff --git a/packages/@react-aria/test-utils/package.json b/packages/@react-aria/test-utils/package.json index 4393a60b129..ec18ecb6981 100644 --- a/packages/@react-aria/test-utils/package.json +++ b/packages/@react-aria/test-utils/package.json @@ -26,6 +26,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/utils": "^3.32.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { diff --git a/packages/@react-aria/test-utils/src/checkboxgroup.ts b/packages/@react-aria/test-utils/src/checkboxgroup.ts index 54e436a5cab..7451b7ec9cb 100644 --- a/packages/@react-aria/test-utils/src/checkboxgroup.ts +++ b/packages/@react-aria/test-utils/src/checkboxgroup.ts @@ -12,6 +12,7 @@ import {act, within} from '@testing-library/react'; import {CheckboxGroupTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; import {pressElement} from './events'; interface TriggerCheckboxOptions { @@ -94,7 +95,7 @@ export class CheckboxGroupTester { throw new Error('Checkbox provided is not in the checkbox group.'); } - if (!this.checkboxgroup.contains(document.activeElement)) { + if (!nodeContains(this.checkboxgroup, document.activeElement)) { act(() => checkboxes[0].focus()); } diff --git a/packages/@react-aria/test-utils/src/combobox.ts b/packages/@react-aria/test-utils/src/combobox.ts index d95ac6f5711..3dfb46c3946 100644 --- a/packages/@react-aria/test-utils/src/combobox.ts +++ b/packages/@react-aria/test-utils/src/combobox.ts @@ -12,6 +12,7 @@ import {act, waitFor, within} from '@testing-library/react'; import {ComboBoxTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; interface ComboBoxOpenOpts { /** @@ -176,7 +177,7 @@ export class ComboBoxTester { if (option.getAttribute('href') == null) { await waitFor(() => { - if (document.contains(listbox)) { + if (nodeContains(document, listbox)) { throw new Error('Expected listbox element to not be in the document after selecting an option'); } else { return true; @@ -198,7 +199,7 @@ export class ComboBoxTester { await this.user.keyboard('[Escape]'); await waitFor(() => { - if (document.contains(listbox)) { + if (nodeContains(document, listbox)) { throw new Error('Expected listbox element to not be in the document after selecting an option'); } else { return true; diff --git a/packages/@react-aria/test-utils/src/dialog.ts b/packages/@react-aria/test-utils/src/dialog.ts index 213c86c2b1a..10e3b22d0f5 100644 --- a/packages/@react-aria/test-utils/src/dialog.ts +++ b/packages/@react-aria/test-utils/src/dialog.ts @@ -12,6 +12,7 @@ import {act, waitFor, within} from '@testing-library/react'; import {DialogTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; interface DialogOpenOpts { /** @@ -96,7 +97,7 @@ export class DialogTester { } }); - if (dialog && document.activeElement !== this._trigger && dialog.contains(document.activeElement)) { + if (dialog && document.activeElement !== this._trigger && nodeContains(dialog, document.activeElement)) { this._dialog = dialog; } else { throw new Error('New modal dialog doesnt contain the active element OR the active element is still the trigger. Uncertain if the proper modal dialog was found'); @@ -113,7 +114,7 @@ export class DialogTester { if (dialog) { await this.user.keyboard('[Escape]'); await waitFor(() => { - if (document.contains(dialog)) { + if (nodeContains(document, dialog)) { throw new Error('Expected the dialog to not be in the document after closing it.'); } else { this._dialog = undefined; @@ -138,6 +139,6 @@ export class DialogTester { * Returns the dialog if present. */ get dialog(): HTMLElement | null { - return this._dialog && document.contains(this._dialog) ? this._dialog : null; + return this._dialog && nodeContains(document, this._dialog) ? this._dialog : null; } } diff --git a/packages/@react-aria/test-utils/src/gridlist.ts b/packages/@react-aria/test-utils/src/gridlist.ts index d5d1c21e082..ebf8af799b6 100644 --- a/packages/@react-aria/test-utils/src/gridlist.ts +++ b/packages/@react-aria/test-utils/src/gridlist.ts @@ -13,6 +13,7 @@ import {act, within} from '@testing-library/react'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {GridListTesterOpts, GridRowActionOpts, ToggleGridRowOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; interface GridListToggleRowOpts extends ToggleGridRowOpts {} interface GridListRowActionOpts extends GridRowActionOpts {} @@ -66,13 +67,13 @@ export class GridListTester { throw new Error('Option provided is not in the gridlist'); } - if (document.activeElement !== this._gridlist && !this._gridlist.contains(document.activeElement)) { + if (document.activeElement !== this._gridlist && !nodeContains(this._gridlist, document.activeElement)) { act(() => this._gridlist.focus()); } if (document.activeElement === this._gridlist) { await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`); - } else if (this._gridlist.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { + } else if (nodeContains(this._gridlist, document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); } while (document.activeElement!.getAttribute('role') !== 'row'); diff --git a/packages/@react-aria/test-utils/src/listbox.ts b/packages/@react-aria/test-utils/src/listbox.ts index cac8d9d78bc..d8fcab4a772 100644 --- a/packages/@react-aria/test-utils/src/listbox.ts +++ b/packages/@react-aria/test-utils/src/listbox.ts @@ -13,6 +13,7 @@ import {act, within} from '@testing-library/react'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {ListBoxTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; interface ListBoxToggleOptionOpts { /** @@ -103,7 +104,7 @@ export class ListBoxTester { throw new Error('Option provided is not in the listbox'); } - if (document.activeElement !== this._listbox && !this._listbox.contains(document.activeElement)) { + if (document.activeElement !== this._listbox && !nodeContains(this._listbox, document.activeElement)) { act(() => this._listbox.focus()); await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`); } diff --git a/packages/@react-aria/test-utils/src/menu.ts b/packages/@react-aria/test-utils/src/menu.ts index 87af5c11fd0..f3dcdd3bdd0 100644 --- a/packages/@react-aria/test-utils/src/menu.ts +++ b/packages/@react-aria/test-utils/src/menu.ts @@ -12,6 +12,7 @@ import {act, waitFor, within} from '@testing-library/react'; import {MenuTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; import {triggerLongPress} from './events'; interface MenuOpenOpts { @@ -215,7 +216,7 @@ export class MenuTester { return; } - if (document.activeElement !== menu && !menu.contains(document.activeElement)) { + if (document.activeElement !== menu && !nodeContains(menu, document.activeElement)) { act(() => menu.focus()); } @@ -262,7 +263,7 @@ export class MenuTester { // close. In React 16, focus actually makes it all the way to the root menu's submenu trigger so we need check the root menu if (this._isSubmenu) { await waitFor(() => { - if (document.activeElement === this.trigger || this._rootMenu?.contains(document.activeElement)) { + if (document.activeElement === this.trigger || nodeContains(this._rootMenu, document.activeElement)) { throw new Error('Expected focus after selecting an submenu option to move away from the original submenu trigger.'); } else { return true; @@ -342,7 +343,7 @@ export class MenuTester { private async keyboardNavigateToOption(opts: {option: HTMLElement}) { let {option} = opts; let options = this.options(); - let targetIndex = options.findIndex(opt => (opt === option) || opt.contains(option)); + let targetIndex = options.findIndex(opt => (opt === option) || nodeContains(opt, option)); if (targetIndex === -1) { throw new Error('Option provided is not in the menu'); @@ -378,7 +379,7 @@ export class MenuTester { } }); - if (document.contains(menu)) { + if (nodeContains(document, menu)) { throw new Error('Expected the menu to not be in the document after closing it.'); } } diff --git a/packages/@react-aria/test-utils/src/radiogroup.ts b/packages/@react-aria/test-utils/src/radiogroup.ts index 6c1d0e38c9e..bdcbfce9730 100644 --- a/packages/@react-aria/test-utils/src/radiogroup.ts +++ b/packages/@react-aria/test-utils/src/radiogroup.ts @@ -12,6 +12,7 @@ import {act, within} from '@testing-library/react'; import {Direction, Orientation, RadioGroupTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; import {pressElement} from './events'; interface TriggerRadioOptions { @@ -94,7 +95,7 @@ export class RadioGroupTester { throw new Error('Radio provided is not in the radio group.'); } - if (!this.radiogroup.contains(document.activeElement)) { + if (!nodeContains(this.radiogroup, document.activeElement)) { let selectedRadio = this.selectedRadio; if (selectedRadio != null) { act(() => selectedRadio.focus()); diff --git a/packages/@react-aria/test-utils/src/select.ts b/packages/@react-aria/test-utils/src/select.ts index 4cce164f53f..56a9372dfe5 100644 --- a/packages/@react-aria/test-utils/src/select.ts +++ b/packages/@react-aria/test-utils/src/select.ts @@ -11,6 +11,7 @@ */ import {act, waitFor, within} from '@testing-library/react'; +import {nodeContains} from '@react-aria/utils'; import {SelectTesterOpts, UserOpts} from './types'; interface SelectOpenOpts { @@ -110,7 +111,7 @@ export class SelectTester { } }); - if (listbox && document.contains(listbox)) { + if (listbox && nodeContains(document, listbox)) { throw new Error('Expected the select element listbox to not be in the document after closing the dropdown.'); } } @@ -191,7 +192,7 @@ export class SelectTester { return; } - if (document.activeElement !== listbox && !listbox.contains(document.activeElement)) { + if (document.activeElement !== listbox && !nodeContains(listbox, document.activeElement)) { act(() => listbox.focus()); } await this.keyboardNavigateToOption({option}); @@ -214,7 +215,7 @@ export class SelectTester { } }); - if (document.contains(listbox)) { + if (nodeContains(document, listbox)) { throw new Error('Expected select element listbox to not be in the document after selecting an option'); } } diff --git a/packages/@react-aria/test-utils/src/table.ts b/packages/@react-aria/test-utils/src/table.ts index 95d4e6fe184..24071fd3b90 100644 --- a/packages/@react-aria/test-utils/src/table.ts +++ b/packages/@react-aria/test-utils/src/table.ts @@ -13,6 +13,7 @@ import {act, waitFor, within} from '@testing-library/react'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {GridRowActionOpts, TableTesterOpts, ToggleGridRowOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; interface TableToggleRowOpts extends ToggleGridRowOpts {} interface TableToggleSortOpts { @@ -65,7 +66,7 @@ export class TableTester { } // Move focus into the table - if (document.activeElement !== this._table && !this._table.contains(document.activeElement)) { + if (document.activeElement !== this._table && !nodeContains(this._table, document.activeElement)) { act(() => this._table.focus()); } @@ -74,14 +75,14 @@ export class TableTester { } // If focus is currently somewhere in the first row group (aka on a column), we want to keyboard navigate downwards till we reach the rows - if (this.rowGroups[0].contains(document.activeElement)) { + if (nodeContains(this.rowGroups[0], document.activeElement)) { do { await this.user.keyboard('[ArrowDown]'); - } while (!this.rowGroups[1].contains(document.activeElement)); + } while (!nodeContains(this.rowGroups[1], document.activeElement)); } // Move focus onto the row itself - if (this.rowGroups[1].contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { + if (nodeContains(this.rowGroups[1], document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); } while (document.activeElement!.getAttribute('role') !== 'row'); @@ -222,7 +223,7 @@ export class TableTester { } await waitFor(() => { - if (document.contains(menu)) { + if (nodeContains(document, menu)) { throw new Error('Expected table column menu listbox to not be in the document after selecting an option'); } else { return true; @@ -308,7 +309,7 @@ export class TableTester { await pressElement(this.user, within(menu).getAllByRole('menuitem')[action], interactionType); await waitFor(() => { - if (document.contains(menu)) { + if (nodeContains(document, menu)) { throw new Error('Expected table column menu listbox to not be in the document after selecting an option'); } else { return true; diff --git a/packages/@react-aria/test-utils/src/tabs.ts b/packages/@react-aria/test-utils/src/tabs.ts index c26da3e7656..f87a2d9afd2 100644 --- a/packages/@react-aria/test-utils/src/tabs.ts +++ b/packages/@react-aria/test-utils/src/tabs.ts @@ -12,6 +12,7 @@ import {act, within} from '@testing-library/react'; import {Direction, Orientation, TabsTesterOpts, UserOpts} from './types'; +import {nodeContains} from '@react-aria/utils'; import {pressElement} from './events'; interface TriggerTabOptions { @@ -89,7 +90,7 @@ export class TabsTester { throw new Error('Tab provided is not in the tablist'); } - if (!this._tablist.contains(document.activeElement)) { + if (!nodeContains(this._tablist, document.activeElement)) { let selectedTab = this.selectedTab; if (selectedTab != null) { act(() => selectedTab.focus()); @@ -142,7 +143,7 @@ export class TabsTester { } if (interactionType === 'keyboard') { - if (document.activeElement !== this._tablist && !this._tablist.contains(document.activeElement)) { + if (document.activeElement !== this._tablist && !nodeContains(this._tablist, document.activeElement)) { act(() => this._tablist.focus()); } diff --git a/packages/@react-aria/test-utils/src/tree.ts b/packages/@react-aria/test-utils/src/tree.ts index cadcf52b72a..28a566d59ec 100644 --- a/packages/@react-aria/test-utils/src/tree.ts +++ b/packages/@react-aria/test-utils/src/tree.ts @@ -13,6 +13,7 @@ import {act, within} from '@testing-library/react'; import {BaseGridRowInteractionOpts, GridRowActionOpts, ToggleGridRowOpts, TreeTesterOpts, UserOpts} from './types'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; +import {nodeContains} from '@react-aria/utils'; interface TreeToggleExpansionOpts extends BaseGridRowInteractionOpts {} interface TreeToggleRowOpts extends ToggleGridRowOpts {} @@ -73,13 +74,13 @@ export class TreeTester { throw new Error('Option provided is not in the tree'); } - if (document.activeElement !== this._tree && !this._tree.contains(document.activeElement)) { + if (document.activeElement !== this._tree && !nodeContains(this._tree, document.activeElement)) { act(() => this._tree.focus()); } if (document.activeElement === this.tree) { await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`); - } else if (this._tree.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { + } else if (nodeContains(this._tree, document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); } while (document.activeElement!.getAttribute('role') !== 'row'); @@ -178,7 +179,7 @@ export class TreeTester { row, interactionType = this._interactionType } = opts; - if (!this.tree.contains(document.activeElement)) { + if (!nodeContains(this.tree, document.activeElement)) { await act(async () => { this.tree.focus(); }); diff --git a/packages/@react-aria/toolbar/src/useToolbar.ts b/packages/@react-aria/toolbar/src/useToolbar.ts index b94bb988c57..7331973d8f9 100644 --- a/packages/@react-aria/toolbar/src/useToolbar.ts +++ b/packages/@react-aria/toolbar/src/useToolbar.ts @@ -12,7 +12,7 @@ import {AriaLabelingProps, Orientation, RefObject} from '@react-types/shared'; import {createFocusManager} from '@react-aria/focus'; -import {filterDOMProps, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, nodeContains, useLayoutEffect} from '@react-aria/utils'; import {HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -56,7 +56,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject { // don't handle portalled events - if (!e.currentTarget.contains(e.target as HTMLElement)) { + if (!nodeContains(e.currentTarget, e.target as HTMLElement)) { return; } if ( @@ -101,7 +101,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject(null); const onBlur = (e) => { - if (!e.currentTarget.contains(e.relatedTarget) && !lastFocused.current) { + if (!nodeContains(e.currentTarget, e.relatedTarget) && !lastFocused.current) { lastFocused.current = e.target; } }; @@ -110,7 +110,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject { - if (lastFocused.current && !e.currentTarget.contains(e.relatedTarget) && ref.current?.contains(e.target)) { + if (lastFocused.current && !nodeContains(e.currentTarget, e.relatedTarget) && nodeContains(ref.current, e.target)) { lastFocused.current?.focus(); lastFocused.current = null; } diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 92e93a53785..76a1e6316cc 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -11,6 +11,7 @@ */ import {getScrollParents} from './getScrollParents'; +import {nodeContains} from './shadowdom/DOMFunctions'; interface ScrollIntoViewportOpts { /** The optional containing element of the target to be centered in the viewport. */ @@ -93,7 +94,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 && document.contains(targetElement)) { + if (targetElement && nodeContains(document, targetElement)) { 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 1f822a0ef17..bb69beb6b08 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -1,4 +1,5 @@ // 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 {shadowDOM} from '@react-stately/flags'; diff --git a/packages/@react-aria/utils/src/useDrag1D.ts b/packages/@react-aria/utils/src/useDrag1D.ts index e907128c9b3..41fe28abac8 100644 --- a/packages/@react-aria/utils/src/useDrag1D.ts +++ b/packages/@react-aria/utils/src/useDrag1D.ts @@ -13,6 +13,7 @@ /* eslint-disable rulesdir/pure-render */ import {getOffset} from './getOffset'; +import {nodeContains} from './shadowdom/DOMFunctions'; import {Orientation} from '@react-types/shared'; import React, {HTMLAttributes, MutableRefObject, useRef} from 'react'; @@ -99,7 +100,7 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes { const target = e.currentTarget; // If we're already handling dragging on a descendant with useDrag1D, then // we don't want to handle the drag motion on this target as well. - if (draggingElements.some(elt => target.contains(elt))) { + if (draggingElements.some(elt => nodeContains(target, elt))) { return; } draggingElements.push(target); diff --git a/packages/@react-spectrum/card/src/CardBase.tsx b/packages/@react-spectrum/card/src/CardBase.tsx index bada5ea8d4e..bc641c5c9a3 100644 --- a/packages/@react-spectrum/card/src/CardBase.tsx +++ b/packages/@react-spectrum/card/src/CardBase.tsx @@ -15,7 +15,7 @@ import {AriaCardProps, SpectrumCardProps} from '@react-types/card'; import {Checkbox} from '@react-spectrum/checkbox'; import {classNames, SlotProvider, useDOMRef, useHasChild, useStyleProps} from '@react-spectrum/utils'; import {DOMRef, Node} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useLayoutEffect, useResizeObserver, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useLayoutEffect, useResizeObserver, useSlotId} from '@react-aria/utils'; import {FocusRing, getFocusableTreeWalker} from '@react-aria/focus'; import React, {HTMLAttributes, useCallback, useMemo, useRef, useState} from 'react'; import styles from '@adobe/spectrum-css-temp/components/card/vars.css'; @@ -104,7 +104,7 @@ export const CardBase = React.forwardRef(function CardBase(pro let walker = getFocusableTreeWalker(gridRef.current); let nextNode = walker.nextNode(); while (nextNode != null) { - if (checkboxRef.current && !checkboxRef.current.UNSAFE_getDOMNode().contains(nextNode)) { + if (checkboxRef.current && !nodeContains(checkboxRef.current.UNSAFE_getDOMNode(), nextNode)) { console.warn('Card does not support focusable elements, please contact the team regarding your use case.'); break; } diff --git a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx index b51d3580b4d..34128d67d45 100644 --- a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx +++ b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx @@ -15,6 +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 {Popover} from '@react-spectrum/overlays'; import React, {JSX, KeyboardEventHandler, ReactElement, useEffect, useRef, useState} from 'react'; import ReactDOM from 'react-dom'; @@ -85,7 +86,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem let [, content] = props.children as [ReactElement, ReactElement]; let onBlurWithin = (e) => { - if (e.relatedTarget && popoverRef.current && (!popoverRef.current.UNSAFE_getDOMNode()?.contains(e.relatedTarget) && !(e.relatedTarget === triggerRef.current && getInteractionModality() === 'pointer'))) { + if (e.relatedTarget && popoverRef.current && (!nodeContains(popoverRef.current.UNSAFE_getDOMNode(), e.relatedTarget) && !(e.relatedTarget === triggerRef.current && getInteractionModality() === 'pointer'))) { if (submenuTriggerState.isOpen) { submenuTriggerState.close(); } @@ -98,7 +99,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem setTraySubmenuAnimation('spectrum-TraySubmenu-exit'); setTimeout(() => { submenuTriggerState.close(); - if (parentMenuRef.current && !parentMenuRef.current.contains(document.activeElement)) { + if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) { 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 186189c7d9e..061e40d55b7 100644 --- a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx @@ -13,7 +13,7 @@ import {classNames, useIsMobileDevice} from '@react-spectrum/utils'; import {Key} from '@react-types/shared'; import {MenuContext, SubmenuTriggerContext, useMenuStateContext} from './context'; -import {mergeProps} from '@react-aria/utils'; +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 && !parentMenuRef.current.contains(document.activeElement)) { + if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) { parentMenuRef.current.focus(); } }; diff --git a/packages/@react-spectrum/menu/src/useCloseOnScroll.ts b/packages/@react-spectrum/menu/src/useCloseOnScroll.ts index 23899dccbf8..64f54860947 100644 --- a/packages/@react-spectrum/menu/src/useCloseOnScroll.ts +++ b/packages/@react-spectrum/menu/src/useCloseOnScroll.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {nodeContains} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect} from 'react'; @@ -39,7 +40,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { // Ignore if scrolling an scrollable region outside the trigger's tree. let target = e.target; // window is not a Node and doesn't have contain, but window contains everything - if (!triggerRef.current || ((target instanceof Node) && !target.contains(triggerRef.current))) { + if (!triggerRef.current || ((target instanceof Node) && !nodeContains(target, triggerRef.current))) { return; } diff --git a/packages/@react-spectrum/menu/src/useOverlayPosition.ts b/packages/@react-spectrum/menu/src/useOverlayPosition.ts index 59c61a08075..ee218a5b7ca 100644 --- a/packages/@react-spectrum/menu/src/useOverlayPosition.ts +++ b/packages/@react-spectrum/menu/src/useOverlayPosition.ts @@ -12,10 +12,10 @@ 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 {useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; export interface AriaPositionProps extends PositionProps { @@ -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 && scrollRef.current.contains(document.activeElement)) { + 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, diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 58a1ebc837b..ed9b7d6afcb 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, useLayoutEffect, useObjectRef} from '@react-aria/utils'; +import {getActiveElement, getOwnerDocument, nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; import {IconContext} from './Icon'; // @ts-ignore @@ -1220,7 +1220,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, if (isOpen) { let activeElement = getActiveElement(getOwnerDocument(formRef.current)); if (activeElement - && formRef.current?.contains(activeElement) + && nodeContains(formRef.current, activeElement) // not going to handle contenteditable https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element // seems like an edge case anyways && ( @@ -1301,7 +1301,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, onOpenChange={setIsOpen} ref={popoverRef} shouldCloseOnInteractOutside={() => { - if (!popoverRef.current?.contains(document.activeElement)) { + if (!nodeContains(popoverRef.current, document.activeElement)) { return false; } formRef.current?.requestSubmit(); diff --git a/packages/@react-spectrum/s2/style/style-macro.ts b/packages/@react-spectrum/s2/style/style-macro.ts index 0f43859218f..8d1fd77b2bb 100644 --- a/packages/@react-spectrum/s2/style/style-macro.ts +++ b/packages/@react-spectrum/s2/style/style-macro.ts @@ -221,7 +221,7 @@ export function createTheme(theme: T): StyleFunction(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?.contains(document.activeElement) && bodyRef.current) { + if (getInteractionModality() === 'keyboard' && headerRef.current && nodeContains(headerRef.current, document.activeElement) && 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 3245a22dec9..4bb1265f0fc 100644 --- a/packages/dev/eslint-plugin-rsp-rules/index.js +++ b/packages/dev/eslint-plugin-rsp-rules/index.js @@ -12,6 +12,7 @@ import actEventsTest from './rules/act-events-test.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'; import sortImports from './rules/sort-imports.js'; @@ -19,7 +20,8 @@ const rules = { 'act-events-test': actEventsTest, 'no-getByRole-toThrow': noGetByRoleToThrow, 'no-react-key': noReactKey, - 'sort-imports': sortImports + 'sort-imports': sortImports, + 'no-non-shadow-contains': noNonShadowContains }; const meta = { diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/no-non-shadow-contains.js b/packages/dev/eslint-plugin-rsp-rules/rules/no-non-shadow-contains.js new file mode 100644 index 00000000000..20490fee4e1 --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/rules/no-non-shadow-contains.js @@ -0,0 +1,114 @@ +/* + * 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: 'Disallow using element.contains in favor of nodeContains for shadow DOM compatibility', + recommended: true + }, + fixable: 'code', + messages: { + useNodeContains: 'Use nodeContains() instead of .contains() for shadow DOM compatibility.' + } + }, + create: (context) => { + let hasNodeContainsImport = false; + let nodeContainsLocalName = 'nodeContains'; + let existingReactAriaUtilsImport = null; + + return { + // Track imports from @react-aria/utils + ImportDeclaration(node) { + if ( + node.source && + node.source.type === 'Literal' && + node.source.value === '@react-aria/utils' + ) { + existingReactAriaUtilsImport = node; + // Check if nodeContains is already imported + const hasNodeContains = node.specifiers.some( + spec => spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'nodeContains' + ); + if (hasNodeContains) { + hasNodeContainsImport = true; + const nodeContainsSpec = node.specifiers.find( + spec => spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'nodeContains' + ); + nodeContainsLocalName = nodeContainsSpec.local.name; + } + } + }, + + // Detect .contains() method calls + ['CallExpression[callee.type=\'MemberExpression\'][callee.property.name=\'contains\']'](node) { + context.report({ + node, + messageId: 'useNodeContains', + fix: (fixer) => { + const fixes = []; + const sourceCode = context.sourceCode; + + // Get the object (e.g., 'element' from 'element.contains(other)') + const objectText = sourceCode.getText(node.callee.object); + + // Get the arguments + const argsText = node.arguments.map(arg => sourceCode.getText(arg)).join(', '); + + // Replace element.contains(other) with nodeContains(element, other) + fixes.push(fixer.replaceText(node, `${nodeContainsLocalName}(${objectText}, ${argsText})`)); + + // Add import if not present + if (!hasNodeContainsImport) { + if (existingReactAriaUtilsImport) { + // Add nodeContains to existing @react-aria/utils import + const specifiers = existingReactAriaUtilsImport.specifiers; + if (specifiers.length > 0) { + fixes.push(fixer.insertTextAfter( + sourceCode.getFirstToken(existingReactAriaUtilsImport, token => token.value === '{'), + 'nodeContains, ' + )); + } + } else { + // No existing import from @react-aria/utils, create a new one + const programNode = context.sourceCode.ast; + const imports = programNode.body.filter(node => node.type === 'ImportDeclaration'); + + if (imports.length > 0) { + const lastImport = imports[imports.length - 1]; + const importStatement = '\nimport {nodeContains} from \'@react-aria/utils\';'; + fixes.push(fixer.insertTextAfter(lastImport, importStatement)); + } else { + // No imports, add at the beginning + const importStatement = 'import {nodeContains} from \'@react-aria/utils\';\n'; + fixes.push(fixer.insertTextBefore(programNode.body[0], importStatement)); + } + } + + // Mark as imported for subsequent fixes in the same file + hasNodeContainsImport = true; + } + + return fixes; + } + }); + } + }; + } +}; + +export default plugin; diff --git a/packages/dev/eslint-plugin-rsp-rules/test/no-non-shadow-contains.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/no-non-shadow-contains.test-lint.js new file mode 100644 index 00000000000..b19c0a284e1 --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/test/no-non-shadow-contains.test-lint.js @@ -0,0 +1,126 @@ +/* + * 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 noNonShadowContainsRule from '../rules/no-non-shadow-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( + 'no-non-shadow-contains', + noNonShadowContainsRule, + { + // 'valid' checks cases that should pass + valid: [ + { + code: ` +import {nodeContains} from '@react-aria/utils'; +if (nodeContains(element, other)) { + console.log('contained'); +}` + }, + { + code: ` +import {nodeContains} from '@react-aria/utils'; +const result = nodeContains(node1, node2);` + } + ], + // 'invalid' checks cases that should not pass + invalid: [ + { + code: ` +if (element.contains(other)) { + console.log('contained'); +}`, + output: ` +import {nodeContains} from '@react-aria/utils'; +if (nodeContains(element, other)) { + console.log('contained'); +}`, + errors: 1 + }, + { + code: ` +import {something} from '@react-aria/utils'; +if (element.contains(other)) { + console.log('contained'); +}`, + output: ` +import {nodeContains, something} from '@react-aria/utils'; +if (nodeContains(element, other)) { + console.log('contained'); +}`, + errors: 1 + }, + { + code: ` +const result = node.contains(child);`, + output: ` +import {nodeContains} from '@react-aria/utils'; +const result = nodeContains(node, child);`, + errors: 1 + }, + { + code: ` +import {nodeContains} from '@react-aria/utils'; +if (element.contains(other)) { + console.log('contained'); +}`, + output: ` +import {nodeContains} from '@react-aria/utils'; +if (nodeContains(element, other)) { + console.log('contained'); +}`, + errors: 1 + }, + { + code: ` +import React from 'react'; +const isContained = ref.current.contains(target);`, + output: ` +import React from 'react'; +import {nodeContains} from '@react-aria/utils'; +const isContained = nodeContains(ref.current, target);`, + errors: 1 + }, + { + code: ` +import {nodeContains} from '@react-aria/utils'; +const a = element1.contains(child1); +const b = element2.contains(child2);`, + output: ` +import {nodeContains} from '@react-aria/utils'; +const a = nodeContains(element1, child1); +const b = nodeContains(element2, child2);`, + errors: 2 + }, + { + code: ` +if (document.body.contains(element)) { + console.log('in body'); +}`, + output: ` +import {nodeContains} from '@react-aria/utils'; +if (nodeContains(document.body, element)) { + console.log('in body'); +}`, + errors: 1 + } + ] + } +); diff --git a/packages/react-aria-components/src/DropZone.tsx b/packages/react-aria-components/src/DropZone.tsx index 1138dd248c0..298179b6ef9 100644 --- a/packages/react-aria-components/src/DropZone.tsx +++ b/packages/react-aria-components/src/DropZone.tsx @@ -21,7 +21,7 @@ import { useRenderProps } from './utils'; import {DropOptions, mergeProps, useButton, useClipboard, useDrop, useFocusRing, useHover, useLocalizedStringFormatter, VisuallyHidden} from 'react-aria'; -import {filterDOMProps, isFocusable, useLabels, useObjectRef, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, isFocusable, nodeContains, useLabels, useObjectRef, useSlotId} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import React, {createContext, ForwardedRef, forwardRef, useRef} from 'react'; @@ -117,7 +117,7 @@ export const DropZone = forwardRef(function DropZone(props: DropZoneProps, ref: ref={dropzoneRef} onClick={(e) => { let target = e.target as HTMLElement | null; - while (target && dropzoneRef.current?.contains(target)) { + while (target && nodeContains(dropzoneRef.current, target)) { if (isFocusable(target)) { break; } else if (target === dropzoneRef.current) { diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index 95d32ba8fbd..49eda1c0880 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -20,7 +20,7 @@ import { useContextProps, useRenderProps } from './utils'; -import {filterDOMProps, mergeProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {OverlayArrowContext} from './OverlayArrow'; import {OverlayTriggerProps, OverlayTriggerState, useOverlayTriggerState} from 'react-stately'; @@ -198,7 +198,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 && !ref.current.contains(document.activeElement)) { + if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !nodeContains(ref.current, document.activeElement)) { focusSafely(ref.current); } }, [isDialog, ref, props.trigger]); diff --git a/yarn.lock b/yarn.lock index 6500c7bc175..30a6caec62d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6129,6 +6129,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-aria/test-utils@workspace:packages/@react-aria/test-utils" dependencies: + "@react-aria/utils": "npm:^3.32.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: "@testing-library/react": ^16.0.0