From 5b9b58cca4ecda22452f5f2cf8393d47495fb775 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 14 Jan 2026 19:05:13 -0800 Subject: [PATCH 1/5] fix: all node.contains for shadow dom usage --- eslint.config.mjs | 2 + .../actiongroup/src/useActionGroup.ts | 4 +- .../calendar/src/useRangeCalendar.ts | 8 +- .../@react-aria/combobox/src/useComboBox.ts | 4 +- .../datepicker/src/useDatePicker.ts | 4 +- .../datepicker/src/useDatePickerGroup.ts | 4 +- .../datepicker/src/useDateRangePicker.ts | 4 +- .../datepicker/src/useDateSegment.ts | 4 +- packages/@react-aria/dialog/src/useDialog.ts | 4 +- packages/@react-aria/dnd/src/DragManager.ts | 16 +-- packages/@react-aria/dnd/src/useDrop.ts | 4 +- packages/@react-aria/focus/src/FocusScope.tsx | 9 +- packages/@react-aria/grid/src/useGrid.ts | 6 +- packages/@react-aria/grid/src/useGridCell.ts | 10 +- .../gridlist/src/useGridListItem.ts | 10 +- .../interactions/src/useFocusWithin.ts | 6 +- .../@react-aria/interactions/src/useHover.ts | 6 +- .../interactions/src/useInteractOutside.ts | 4 +- .../@react-aria/interactions/src/usePress.ts | 2 +- .../@react-aria/landmark/src/useLandmark.ts | 8 +- .../menu/src/useSafelyMouseToSubmenu.ts | 4 +- .../@react-aria/menu/src/useSubmenuTrigger.ts | 12 +- .../overlays/src/ariaHideOutside.ts | 6 +- .../overlays/src/calculatePosition.ts | 4 +- .../overlays/src/useCloseOnScroll.ts | 3 +- .../overlays/src/useOverlayPosition.ts | 4 +- packages/@react-aria/select/src/useSelect.ts | 4 +- .../selection/src/useSelectableCollection.ts | 14 +- .../selection/src/useTypeSelect.ts | 3 +- packages/@react-aria/test-utils/package.json | 1 + .../test-utils/src/checkboxgroup.ts | 3 +- .../@react-aria/test-utils/src/combobox.ts | 5 +- packages/@react-aria/test-utils/src/dialog.ts | 7 +- .../@react-aria/test-utils/src/gridlist.ts | 5 +- .../@react-aria/test-utils/src/listbox.ts | 3 +- packages/@react-aria/test-utils/src/menu.ts | 9 +- .../@react-aria/test-utils/src/radiogroup.ts | 3 +- packages/@react-aria/test-utils/src/select.ts | 7 +- packages/@react-aria/test-utils/src/table.ts | 13 +- packages/@react-aria/test-utils/src/tabs.ts | 5 +- packages/@react-aria/test-utils/src/tree.ts | 7 +- .../@react-aria/toolbar/src/useToolbar.ts | 8 +- .../@react-aria/utils/src/scrollIntoView.ts | 5 +- .../utils/src/shadowdom/DOMFunctions.ts | 1 + packages/@react-aria/utils/src/useDrag1D.ts | 3 +- .../@react-spectrum/card/src/CardBase.tsx | 4 +- .../menu/src/ContextualHelpTrigger.tsx | 5 +- .../menu/src/SubmenuTrigger.tsx | 4 +- .../menu/src/useCloseOnScroll.ts | 3 +- .../menu/src/useOverlayPosition.ts | 4 +- packages/@react-spectrum/s2/src/TableView.tsx | 6 +- .../@react-spectrum/s2/style/style-macro.ts | 6 +- .../table/src/TableViewBase.tsx | 4 +- packages/dev/eslint-plugin-rsp-rules/index.js | 4 +- .../rules/no-non-shadow-contains.js | 114 ++++++++++++++++ .../test/no-non-shadow-contains.test-lint.js | 126 ++++++++++++++++++ .../react-aria-components/src/DropZone.tsx | 4 +- .../react-aria-components/src/Popover.tsx | 4 +- 58 files changed, 405 insertions(+), 141 deletions(-) create mode 100644 packages/dev/eslint-plugin-rsp-rules/rules/no-non-shadow-contains.js create mode 100644 packages/dev/eslint-plugin-rsp-rules/test/no-non-shadow-contains.test-lint.js 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..acf42a48816 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)) { 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 b9caaec7f6b..7b918707908 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -450,7 +450,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..9dac11215fc 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 (!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 57c1a09fefe..174b5a34f84 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 '@react-aria/utils'; interface ScrollIntoViewportOpts { /** The optional containing element of the target to be centered in the viewport. */ @@ -80,7 +81,7 @@ function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'| if (child.offsetParent === ancestor) { // Stop once we have found the ancestor we are interested in. break; - } else if (child.offsetParent.contains(ancestor)) { + } else if (nodeContains(child.offsetParent, ancestor)) { // If the ancestor is not `position:relative`, then we stop at // _its_ offset parent, and we subtract off _its_ offset, so that // we end up with the proper offset from child to ancestor. @@ -98,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 && 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..09f1ee39721 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 '@react-aria/utils'; 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' && 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]); From e5f00eb3d7efc540d928ff94af74fbcc0b46605a Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 14 Jan 2026 19:14:09 -0800 Subject: [PATCH 2/5] fix lint and yarn lock --- packages/@react-aria/datepicker/src/useDatePickerGroup.ts | 2 +- packages/@react-aria/selection/src/useSelectableCollection.ts | 2 +- packages/@react-spectrum/table/src/TableViewBase.tsx | 2 +- yarn.lock | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts index acf42a48816..b8431acc549 100644 --- a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts +++ b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts @@ -12,7 +12,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState // Open the popover on alt + arrow down let onKeyDown = (e: KeyboardEvent) => { - if (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, e.target as Element)) { return; } diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 9dac11215fc..39e5dd7fe45 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -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 (!nodeContains(ref.current, e.target as Element)) { + if (!ref.current || !nodeContains(ref.current, e.target as Element)) { return; } diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 3dab626c8c3..443303f5fb2 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -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' && nodeContains(headerRef.current, 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/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 From dfddffe4c8f2aba740c2d3017d44ea583bbb1758 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 14 Jan 2026 19:25:09 -0800 Subject: [PATCH 3/5] fix esm test --- packages/@react-aria/utils/src/scrollIntoView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 174b5a34f84..336b0ebc2c9 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -11,7 +11,7 @@ */ import {getScrollParents} from './getScrollParents'; -import {nodeContains} from '@react-aria/utils'; +import {nodeContains} from './shadowdom/DOMFunctions'; interface ScrollIntoViewportOpts { /** The optional containing element of the target to be centered in the viewport. */ From 4289f82f0a9c8fbb9d17086437e7373ab0107bc5 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 14 Jan 2026 19:34:40 -0800 Subject: [PATCH 4/5] fix another esm test --- packages/@react-aria/utils/src/useDrag1D.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/utils/src/useDrag1D.ts b/packages/@react-aria/utils/src/useDrag1D.ts index 09f1ee39721..41fe28abac8 100644 --- a/packages/@react-aria/utils/src/useDrag1D.ts +++ b/packages/@react-aria/utils/src/useDrag1D.ts @@ -13,7 +13,7 @@ /* eslint-disable rulesdir/pure-render */ import {getOffset} from './getOffset'; -import {nodeContains} from '@react-aria/utils'; +import {nodeContains} from './shadowdom/DOMFunctions'; import {Orientation} from '@react-types/shared'; import React, {HTMLAttributes, MutableRefObject, useRef} from 'react'; From 2602f6d6a8634dbc2d34819bbf637b5ad75a20ba Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 14 Jan 2026 21:02:26 -0800 Subject: [PATCH 5/5] fix: all event.target for shadow dom --- eslint.config.mjs | 3 + .../actiongroup/src/useActionGroup.ts | 4 +- .../autocomplete/src/useAutocomplete.ts | 10 +- .../calendar/src/useCalendarCell.ts | 12 +- .../calendar/src/useRangeCalendar.ts | 4 +- .../@react-aria/color/src/useColorWheel.ts | 4 +- .../@react-aria/combobox/src/useComboBox.ts | 4 +- .../datepicker/src/useDatePickerGroup.ts | 8 +- packages/@react-aria/dnd/src/DragManager.ts | 28 ++-- packages/@react-aria/dnd/src/useDrag.ts | 18 +-- packages/@react-aria/dnd/src/useDrop.ts | 6 +- packages/@react-aria/grid/src/useGrid.ts | 6 +- packages/@react-aria/grid/src/useGridCell.ts | 8 +- .../gridlist/src/useGridListItem.ts | 10 +- .../@react-aria/interactions/src/useFocus.ts | 6 +- .../interactions/src/useFocusVisible.ts | 14 +- .../interactions/src/useFocusWithin.ts | 10 +- .../@react-aria/interactions/src/useHover.ts | 12 +- .../interactions/src/useInteractOutside.ts | 10 +- .../interactions/src/useLongPress.ts | 12 +- .../@react-aria/interactions/src/usePress.ts | 6 +- .../@react-aria/interactions/src/utils.ts | 20 +-- .../@react-aria/landmark/src/useLandmark.ts | 12 +- packages/@react-aria/menu/src/useMenuItem.ts | 10 +- .../@react-aria/menu/src/useMenuTrigger.ts | 6 +- .../@react-aria/menu/src/useSubmenuTrigger.ts | 10 +- .../numberfield/src/useNumberField.ts | 4 +- .../overlays/src/useCloseOnScroll.ts | 6 +- .../@react-aria/overlays/src/useOverlay.ts | 7 +- .../overlays/src/usePreventScroll.ts | 6 +- .../@react-aria/radio/src/useRadioGroup.ts | 4 +- .../@react-aria/select/src/HiddenSelect.tsx | 6 +- .../selection/src/useSelectableCollection.ts | 10 +- .../selection/src/useSelectableItem.ts | 4 +- .../selection/src/useTypeSelect.ts | 4 +- .../@react-aria/slider/src/useSliderThumb.ts | 4 +- .../table/src/useTableColumnResize.ts | 6 +- .../textfield/src/useFormattedTextField.ts | 6 +- .../@react-aria/textfield/src/useTextField.ts | 4 +- .../@react-aria/toast/src/useToastRegion.ts | 4 +- packages/@react-aria/toggle/src/useToggle.ts | 4 +- .../@react-aria/toolbar/src/useToolbar.ts | 8 +- .../utils/src/runAfterTransition.ts | 17 +-- .../utils/src/shadowdom/DOMFunctions.ts | 7 +- packages/@react-aria/utils/src/useDrag1D.ts | 4 +- .../@react-aria/utils/src/useViewportSize.ts | 3 +- .../virtualizer/src/ScrollView.tsx | 4 +- .../menu/src/useCloseOnScroll.ts | 6 +- packages/@react-spectrum/s2/src/Field.tsx | 8 +- packages/@react-spectrum/s2/src/Toast.tsx | 4 +- packages/dev/eslint-plugin-rsp-rules/index.js | 4 +- .../rules/safe-event-target.js | 132 ++++++++++++++++++ .../test/safe-event-target.test-lint.js | 129 +++++++++++++++++ .../react-aria-components/src/DropZone.tsx | 4 +- .../react-aria-components/src/FileTrigger.tsx | 4 +- .../src/HiddenDateInput.tsx | 3 +- packages/react-aria-components/src/Tree.tsx | 6 +- 57 files changed, 474 insertions(+), 201 deletions(-) create mode 100644 packages/dev/eslint-plugin-rsp-rules/rules/safe-event-target.js create mode 100644 packages/dev/eslint-plugin-rsp-rules/test/safe-event-target.test-lint.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 809ac66aef2..0fb4120e8a4 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/safe-event-target": [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/safe-event-target": OFF, "rulesdir/imports": OFF, "monorepo/no-internal-import": OFF, "jsdoc/require-jsdoc": OFF @@ -470,6 +472,7 @@ export default [{ rules: { "jsdoc/require-jsdoc": OFF, "jsdoc/require-description": OFF, + "rsp-rules/safe-event-target": OFF, }, }, { files: [ diff --git a/packages/@react-aria/actiongroup/src/useActionGroup.ts b/packages/@react-aria/actiongroup/src/useActionGroup.ts index c2afb86c9a7..d5746316df6 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, nodeContains, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, 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 (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 291cd9065bb..e826dac7dcc 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -13,7 +13,7 @@ import {AriaLabelingProps, BaseEvent, DOMProps, FocusableElement, FocusEvents, KeyboardEvents, Node, RefObject, ValueBase} from '@react-types/shared'; import {AriaTextFieldProps} from '@react-aria/textfield'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useLayoutEffect, useObjectRef} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getEventTarget, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus'; import {getInteractionModality, getPointerType} from '@react-aria/interactions'; // @ts-ignore @@ -112,7 +112,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut inputRef.current.focus(); } - let target = e.target as Element | null; + let target = getEventTarget(e) as Element | null; if (e.isTrusted || !target || queuedActiveDescendant.current === target.id) { return; } @@ -225,7 +225,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let keyDownTarget = useRef(null); // For textfield specific keydown operations let onKeyDown = (e: BaseEvent>) => { - keyDownTarget.current = e.target as Element; + keyDownTarget.current = getEventTarget(e) as Element; if (e.nativeEvent.isComposing) { return; } @@ -329,7 +329,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut // Dispatch simulated key up events for things like triggering links in listbox // Make sure to stop the propagation of the input keyup event so that the simulated keyup/down pair // is detected by usePress instead of the original keyup originating from the input - if (e.target === keyDownTarget.current) { + if (getEventTarget(e) === keyDownTarget.current) { e.stopImmediatePropagation(); let focusedNodeId = queuedActiveDescendant.current; if (focusedNodeId == null) { @@ -386,7 +386,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let curFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null; if (curFocusedNode) { - let target = e.target; + let target = getEventTarget(e); queueMicrotask(() => { // instead of focusing the last focused node, just focus the collection instead and have the collection handle what item to focus via useSelectableCollection/Item dispatchVirtualBlur(target, collectionRef.current); diff --git a/packages/@react-aria/calendar/src/useCalendarCell.ts b/packages/@react-aria/calendar/src/useCalendarCell.ts index 9db8185c699..3d33897125f 100644 --- a/packages/@react-aria/calendar/src/useCalendarCell.ts +++ b/packages/@react-aria/calendar/src/useCalendarCell.ts @@ -13,7 +13,7 @@ import {CalendarDate, isEqualDay, isSameDay, isToday} from '@internationalized/date'; import {CalendarState, RangeCalendarState} from '@react-stately/calendar'; import {DOMAttributes, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, getScrollParent, mergeProps, scrollIntoViewport, useDeepMemo, useDescription} from '@react-aria/utils'; +import {focusWithoutScrolling, getEventTarget, getScrollParent, mergeProps, scrollIntoViewport, useDeepMemo, useDescription} from '@react-aria/utils'; import {getEraFormat, hookData} from './utils'; import {getInteractionModality, usePress} from '@react-aria/interactions'; // @ts-ignore @@ -337,13 +337,13 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta // This is necessary on touch devices to allow dragging // outside the original pressed element. // (JSDOM does not support this) - if ('releasePointerCapture' in e.target) { - if ('hasPointerCapture' in e.target) { - if (e.target.hasPointerCapture(e.pointerId)) { - e.target.releasePointerCapture(e.pointerId); + if ('releasePointerCapture' in getEventTarget(e)) { + if ('hasPointerCapture' in getEventTarget(e)) { + if (getEventTarget(e).hasPointerCapture(e.pointerId)) { + getEventTarget(e).releasePointerCapture(e.pointerId); } } else { - e.target.releasePointerCapture(e.pointerId); + getEventTarget(e).releasePointerCapture(e.pointerId); } } }, diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index f228c77b477..f08726fed88 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 {getEventTarget, nodeContains, useEvent} from '@react-aria/utils'; import {RangeCalendarState} from '@react-stately/calendar'; import {useRef} from 'react'; @@ -49,7 +49,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr return; } - let target = e.target as Element; + let target = getEventTarget(e) as Element; if ( ref.current && nodeContains(ref.current, document.activeElement) && diff --git a/packages/@react-aria/color/src/useColorWheel.ts b/packages/@react-aria/color/src/useColorWheel.ts index bcfa244cda8..789e9312784 100644 --- a/packages/@react-aria/color/src/useColorWheel.ts +++ b/packages/@react-aria/color/src/useColorWheel.ts @@ -13,7 +13,7 @@ import {AriaColorWheelProps} from '@react-types/color'; import {ColorWheelState} from '@react-stately/color'; import {DOMAttributes, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, mergeProps, useFormReset, useGlobalListeners, useLabels} from '@react-aria/utils'; +import {focusWithoutScrolling, getEventTarget, mergeProps, useFormReset, useGlobalListeners, useLabels} from '@react-aria/utils'; import React, {ChangeEvent, InputHTMLAttributes, useCallback, useRef} from 'react'; import {useKeyboard, useMove} from '@react-aria/interactions'; import {useLocale} from '@react-aria/i18n'; @@ -328,7 +328,7 @@ export function useColorWheel(props: AriaColorWheelOptions, state: ColorWheelSta name, form, onChange: (e: ChangeEvent) => { - state.setHue(parseFloat(e.target.value)); + state.setHue(parseFloat(getEventTarget(e).value)); }, style: visuallyHiddenProps.style, 'aria-errormessage': props['aria-errormessage'], diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index c8db04c19cc..e41da6f4b7c 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, nodeContains, useEvent, useFormReset, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; +import {chain, getActiveElement, getEventTarget, 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'; @@ -264,7 +264,7 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta return; } - let rect = (e.target as Element).getBoundingClientRect(); + let rect = (getEventTarget(e) as Element).getBoundingClientRect(); let touch = e.changedTouches[0]; let centerX = Math.ceil(rect.left + .5 * rect.width); diff --git a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts index b8431acc549..1999a54be2c 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, nodeContains} from '@react-aria/utils'; +import {getEventTarget, 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 (!nodeContains(e.currentTarget, e.target as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element)) { return; } @@ -32,7 +32,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState e.stopPropagation(); if (direction === 'rtl') { if (ref.current) { - let target = e.target as FocusableElement; + let target = getEventTarget(e) as FocusableElement; let prev = findNextSegment(ref.current, target.getBoundingClientRect().left, -1); if (prev) { @@ -48,7 +48,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState e.stopPropagation(); if (direction === 'rtl') { if (ref.current) { - let target = e.target as FocusableElement; + let target = getEventTarget(e) as FocusableElement; let next = findNextSegment(ref.current, target.getBoundingClientRect().left, 1); if (next) { diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts index 0a4be7afba1..e80f587b2c8 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, nodeContains} from '@react-aria/utils'; +import {getEventTarget, isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils'; import type {LocalizedStringFormatter} from '@internationalized/string'; import {RefObject, useEffect, useState} from 'react'; @@ -243,7 +243,7 @@ class DragSession { this.cancelEvent(e); if (e.key === 'Enter') { - if (e.altKey || nodeContains(this.getCurrentActivateButton(), e.target as Node)) { + if (e.altKey || nodeContains(this.getCurrentActivateButton(), getEventTarget(e) as Node)) { this.activate(this.currentDropTarget, this.currentDropItem); } else { this.drop(); @@ -257,25 +257,25 @@ class DragSession { onFocus(e: FocusEvent): void { let activateButton = this.getCurrentActivateButton(); - if (e.target === activateButton) { + if (getEventTarget(e) === activateButton) { // TODO: canceling this breaks the focus ring. Revisit when we support tabbing. this.cancelEvent(e); return; } // Prevent focus events, except to the original drag target. - if (e.target !== this.dragTarget.element) { + if (getEventTarget(e) !== this.dragTarget.element) { this.cancelEvent(e); } // Ignore focus events on the window/document (JSDOM). Will be handled in onBlur, below. - if (!(e.target instanceof HTMLElement) || e.target === this.dragTarget.element) { + if (!(getEventTarget(e) instanceof HTMLElement) || getEventTarget(e) === this.dragTarget.element) { return; } let dropTarget = - this.validDropTargets.find(target => target.element === e.target as HTMLElement) || - this.validDropTargets.find(target => nodeContains(target.element, e.target as HTMLElement)); + this.validDropTargets.find(target => target.element === getEventTarget(e) as HTMLElement) || + this.validDropTargets.find(target => nodeContains(target.element, getEventTarget(e) as HTMLElement)); if (!dropTarget) { // if (e.target === activateButton) { @@ -289,7 +289,7 @@ class DragSession { return; } - let item = dropItems.get(e.target as HTMLElement); + let item = dropItems.get(getEventTarget(e) as HTMLElement); if (dropTarget) { this.setCurrentDropTarget(dropTarget, item); } @@ -302,7 +302,7 @@ class DragSession { return; } - if (e.target !== this.dragTarget.element) { + if (getEventTarget(e) !== this.dragTarget.element) { this.cancelEvent(e); } @@ -321,15 +321,15 @@ 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 || nodeContains(item.activateButtonRef?.current, e.target as HTMLElement)); - let dropTarget = this.validDropTargets.find(target => nodeContains(target.element, e.target as HTMLElement)); + let item = [...dropElements].find(item => item.element === getEventTarget(e) as HTMLElement || nodeContains(item.activateButtonRef?.current, getEventTarget(e) as HTMLElement)); + let dropTarget = this.validDropTargets.find(target => nodeContains(target.element, getEventTarget(e) as HTMLElement)); let activateButton = item?.activateButtonRef?.current ?? dropTarget?.activateButtonRef?.current; - if (nodeContains(activateButton, e.target as HTMLElement) && dropTarget) { + if (nodeContains(activateButton, getEventTarget(e) as HTMLElement) && dropTarget) { this.activate(dropTarget, item); return; } - if (e.target === this.dragTarget.element) { + if (getEventTarget(e) === this.dragTarget.element) { this.cancel(); return; } @@ -350,7 +350,7 @@ class DragSession { cancelEvent(e: Event): void { // Allow focusin and focusout on the drag target so focus ring works properly. - if ((e.type === 'focusin' || e.type === 'focusout') && (e.target === this.dragTarget?.element || e.target === this.getCurrentActivateButton())) { + if ((e.type === 'focusin' || e.type === 'focusout') && (getEventTarget(e) === this.dragTarget?.element || getEventTarget(e) === this.getCurrentActivateButton())) { return; } diff --git a/packages/@react-aria/dnd/src/useDrag.ts b/packages/@react-aria/dnd/src/useDrag.ts index 6cfbb9be7b1..6bc93d193d1 100644 --- a/packages/@react-aria/dnd/src/useDrag.ts +++ b/packages/@react-aria/dnd/src/useDrag.ts @@ -15,10 +15,10 @@ import {DragEndEvent, DragItem, DragMoveEvent, DragPreviewRenderer, DragStartEve import {DragEvent, HTMLAttributes, version as ReactVersion, useEffect, useRef, useState} from 'react'; import * as DragManager from './DragManager'; import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, EFFECT_ALLOWED} from './constants'; -import {globalDropEffect, setGlobalAllowedDropOperations, setGlobalDropEffect, useDragModality, writeToDataTransfer} from './utils'; +import {getEventTarget, isVirtualClick, isVirtualPointerEvent, useDescription, useGlobalListeners} from '@react-aria/utils'; // @ts-ignore +import {globalDropEffect, setGlobalAllowedDropOperations, setGlobalDropEffect, useDragModality, writeToDataTransfer} from './utils'; import intlMessages from '../intl/*.json'; -import {isVirtualClick, isVirtualPointerEvent, useDescription, useGlobalListeners} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; export interface DragOptions { @@ -102,7 +102,7 @@ export function useDrag(options: DragOptions): DragResult { // If this drag was initiated by a mobile screen reader (e.g. VoiceOver or TalkBack), enter virtual dragging mode. if (modalityOnPointerDown.current === 'virtual') { e.preventDefault(); - startDragging(e.target as HTMLElement); + startDragging(getEventTarget(e) as HTMLElement); modalityOnPointerDown.current = null; return; } @@ -188,7 +188,7 @@ export function useDrag(options: DragOptions): DragResult { // Wait a frame before we set dragging to true so that the browser has time to // render the preview image before we update the element that has been dragged. - let target = e.target; + let target = getEventTarget(e); requestAnimationFrame(() => { setDragging(target as Element); }); @@ -271,7 +271,7 @@ export function useDrag(options: DragOptions): DragResult { return; } - startDragging(e.target as HTMLElement); + startDragging(getEventTarget(e) as HTMLElement); }; let startDragging = (target: HTMLElement) => { @@ -340,16 +340,16 @@ export function useDrag(options: DragOptions): DragResult { } }, onKeyDownCapture(e) { - if (e.target === e.currentTarget && e.key === 'Enter') { + if (getEventTarget(e) === e.currentTarget && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); } }, onKeyUpCapture(e) { - if (e.target === e.currentTarget && e.key === 'Enter') { + if (getEventTarget(e) === e.currentTarget && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); - startDragging(e.target as HTMLElement); + startDragging(getEventTarget(e) as HTMLElement); } }, onClick(e) { @@ -357,7 +357,7 @@ export function useDrag(options: DragOptions): DragResult { if (isVirtualClick(e.nativeEvent) || modalityOnPointerDown.current === 'virtual') { e.preventDefault(); e.stopPropagation(); - startDragging(e.target as HTMLElement); + startDragging(getEventTarget(e) as HTMLElement); } } }; diff --git a/packages/@react-aria/dnd/src/useDrop.ts b/packages/@react-aria/dnd/src/useDrop.ts index 03d0fc92f2a..e5273ab8702 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, nodeContains, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {getEventTarget, isIPad, isMac, nodeContains, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {useVirtualDrop} from './useVirtualDrop'; export interface DropOptions { @@ -186,7 +186,7 @@ export function useDrop(options: DropOptions): DropResult { let onDragEnter = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); - state.dragOverElements.add(e.target as Element); + state.dragOverElements.add(getEventTarget(e) as Element); if (state.dragOverElements.size > 1) { return; } @@ -232,7 +232,7 @@ export function useDrop(options: DropOptions): DropResult { // events will never be fired for these. This can happen, for example, with drop // indicators between items, which disappear when the drop target changes. - state.dragOverElements.delete(e.target as Element); + state.dragOverElements.delete(getEventTarget(e) as Element); for (let element of state.dragOverElements) { if (!nodeContains(e.currentTarget, element)) { state.dragOverElements.delete(element); diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 5c6ba84eec0..908831e19be 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, nodeContains, useId} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, 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 (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { manager.setFocused(false); } @@ -144,7 +144,7 @@ export function useGrid(props: GridProps, state: GridState>(props: GridCellProps }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!nodeContains(e.currentTarget, e.target as Element) || state.isKeyboardNavigationDisabled || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, getEventTarget(e) 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 && nodeContains(ref.current, e.target as Element)) { + if (!e.altKey && nodeContains(ref.current, getEventTarget(e) as Element)) { e.stopPropagation(); e.preventDefault(); ref.current.parentElement?.dispatchEvent( @@ -228,7 +228,7 @@ export function useGridCell>(props: GridCellProps // be marshalled to that element rather than focusing the cell itself. let onFocus = (e) => { keyWhenFocused.current = node.key; - if (e.target !== ref.current) { + if (getEventTarget(e) !== ref.current) { // useSelectableItem only handles setting the focused key when // the focused element is the gridcell itself. We also want to // set the focused key when a child element receives focus. diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 6a1bd8e27c1..de1b1e98e2a 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, getEventTarget, 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'; @@ -131,7 +131,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, getEventTarget(e) 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 && nodeContains(ref.current, e.target as Element)) { + if (!e.altKey && nodeContains(ref.current, getEventTarget(e) as Element)) { e.stopPropagation(); e.preventDefault(); ref.current.parentElement?.dispatchEvent( @@ -229,7 +229,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt let onFocus = (e) => { keyWhenFocused.current = node.key; - if (e.target !== ref.current) { + if (getEventTarget(e) !== ref.current) { // useSelectableItem only handles setting the focused key when // the focused element is the row itself. We also want to // set the focused key when a child element receives focus. @@ -244,7 +244,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }; let onKeyDown = (e) => { - if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || !document.activeElement) { return; } diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index d2c910ecedd..5adfa393a53 100644 --- a/packages/@react-aria/interactions/src/useFocus.ts +++ b/packages/@react-aria/interactions/src/useFocus.ts @@ -43,7 +43,7 @@ export function useFocus(pro } = props; const onBlur: FocusProps['onBlur'] = useCallback((e: FocusEvent) => { - if (e.target === e.currentTarget) { + if (getEventTarget(e) === e.currentTarget) { if (onBlurProp) { onBlurProp(e); } @@ -63,9 +63,9 @@ export function useFocus(pro // Double check that document.activeElement actually matches e.target in case a previously chained // focus handler already moved focus somewhere else. - const ownerDocument = getOwnerDocument(e.target); + const ownerDocument = getOwnerDocument(getEventTarget(e)); const activeElement = ownerDocument ? getActiveElement(ownerDocument) : getActiveElement(); - if (e.target === e.currentTarget && activeElement === getEventTarget(e.nativeEvent)) { + if (getEventTarget(e) === e.currentTarget && activeElement === getEventTarget(e.nativeEvent)) { if (onFocusProp) { onFocusProp(e); } diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index 07626782dea..aa225a01c3a 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.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, getOwnerWindow, isMac, isVirtualClick, openLink} from '@react-aria/utils'; +import {getEventTarget, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, openLink} from '@react-aria/utils'; import {ignoreFocusEvent} from './utils'; import {PointerType} from '@react-types/shared'; import {useEffect, useState} from 'react'; @@ -98,7 +98,7 @@ function handleFocusEvent(e: FocusEvent) { // Firefox fires two extra focus events when the user first clicks into an iframe: // first on the window, then on the document. We ignore these events so they don't // cause keyboard focus rings to appear. - if (e.target === window || e.target === document || ignoreFocusEvent || !e.isTrusted) { + if (getEventTarget(e) === window || getEventTarget(e) === document || ignoreFocusEvent || !e.isTrusted) { return; } @@ -302,11 +302,11 @@ const nonTextInputTypes = new Set([ * focus visible style can be properly set. */ function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: HandlerEvent) { - let document = getOwnerDocument(e?.target as Element); - const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLInputElement : HTMLInputElement; - const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLTextAreaElement : HTMLTextAreaElement; - const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLElement : HTMLElement; - const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).KeyboardEvent : KeyboardEvent; + let document = getOwnerDocument(getEventTarget(e) as Element); + const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(getEventTarget(e) as Element).HTMLInputElement : HTMLInputElement; + const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(getEventTarget(e) as Element).HTMLTextAreaElement : HTMLTextAreaElement; + const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(getEventTarget(e) as Element).HTMLElement : HTMLElement; + const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(getEventTarget(e) as Element).KeyboardEvent : KeyboardEvent; // For keyboard events that occur on a non-input element that will move focus into input element (aka ArrowLeft going from Datepicker button to the main input group) // we need to rely on the user passing isTextInput into here. This way we can skip toggling focus visiblity for said input element diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 1faf3127c32..9a5f0c3c912 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -54,7 +54,7 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onBlur = useCallback((e: FocusEvent) => { // Ignore events bubbling through portals. - if (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -78,13 +78,13 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onSyntheticFocus = useSyntheticBlurEvent(onBlur); let onFocus = useCallback((e: FocusEvent) => { // Ignore events bubbling through portals. - if (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } // Double check that document.activeElement actually matches e.target in case a previously chained // focus handler already moved focus somewhere else. - const ownerDocument = getOwnerDocument(e.target); + const ownerDocument = getOwnerDocument(getEventTarget(e)); const activeElement = getActiveElement(ownerDocument); if (!state.current.isFocusWithin && activeElement === getEventTarget(e.nativeEvent)) { if (onFocusWithin) { @@ -103,8 +103,8 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { // can manually fire onBlur. let currentTarget = e.currentTarget; addGlobalListener(ownerDocument, 'focus', e => { - if (state.current.isFocusWithin && !nodeContains(currentTarget, e.target as Element)) { - let nativeEvent = new ownerDocument.defaultView!.FocusEvent('blur', {relatedTarget: e.target}); + if (state.current.isFocusWithin && !nodeContains(currentTarget, getEventTarget(e) as Element)) { + let nativeEvent = new ownerDocument.defaultView!.FocusEvent('blur', {relatedTarget: getEventTarget(e)}); setEventTarget(nativeEvent, currentTarget); let event = createSyntheticEvent(nativeEvent); onBlur(event); diff --git a/packages/@react-aria/interactions/src/useHover.ts b/packages/@react-aria/interactions/src/useHover.ts index cde3c286128..30190cee0bd 100644 --- a/packages/@react-aria/interactions/src/useHover.ts +++ b/packages/@react-aria/interactions/src/useHover.ts @@ -16,7 +16,7 @@ // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions import {DOMAttributes, HoverEvents} from '@react-types/shared'; -import {getOwnerDocument, nodeContains, useGlobalListeners} from '@react-aria/utils'; +import {getEventTarget, getOwnerDocument, nodeContains, useGlobalListeners} from '@react-aria/utils'; import {useEffect, useMemo, useRef, useState} from 'react'; export interface HoverProps extends HoverEvents { @@ -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 || !nodeContains(event.currentTarget, event.target)) { + if (isDisabled || pointerType === 'touch' || state.isHovered || !nodeContains(event.currentTarget, getEventTarget(event))) { return; } @@ -120,8 +120,8 @@ export function useHover(props: HoverProps): HoverResult { // even though the originally hovered target may have shrunk in size so it is no longer hovered. // However, a pointerover event will be fired on the new target the mouse is over. // In Chrome this happens immediately. In Safari and Firefox, it happens upon moving the mouse one pixel. - addGlobalListener(getOwnerDocument(event.target), 'pointerover', e => { - if (state.isHovered && state.target && !nodeContains(state.target, e.target as Element)) { + addGlobalListener(getOwnerDocument(getEventTarget(event)), 'pointerover', e => { + if (state.isHovered && state.target && !nodeContains(state.target, getEventTarget(e) as Element)) { triggerHoverEnd(e, e.pointerType); } }, {capture: true}); @@ -180,7 +180,7 @@ export function useHover(props: HoverProps): HoverResult { }; hoverProps.onPointerLeave = (e) => { - if (!isDisabled && nodeContains(e.currentTarget, e.target as Element)) { + if (!isDisabled && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { triggerHoverEnd(e, e.pointerType); } }; @@ -198,7 +198,7 @@ export function useHover(props: HoverProps): HoverResult { }; hoverProps.onMouseLeave = (e) => { - if (!isDisabled && nodeContains(e.currentTarget, e.target as Element)) { + if (!isDisabled && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { triggerHoverEnd(e, 'mouse'); } }; diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts index b9580fabc1d..b6dba141cef 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, nodeContains, useEffectEvent} from '@react-aria/utils'; +import {getEventTarget, getOwnerDocument, nodeContains, useEffectEvent} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect, useRef} from 'react'; @@ -118,14 +118,14 @@ function isValidEvent(event, ref) { if (event.button > 0) { return false; } - if (event.target) { + if (getEventTarget(event)) { // if the event target is no longer in the document, ignore - const ownerDocument = event.target.ownerDocument; - if (!ownerDocument || !nodeContains(ownerDocument.documentElement, event.target)) { + const ownerDocument = getEventTarget(event).ownerDocument; + if (!ownerDocument || !nodeContains(ownerDocument.documentElement, getEventTarget(event))) { return false; } // If the target is within a top layer element (e.g. toasts), ignore. - if (event.target.closest('[data-react-aria-top-layer]')) { + if (getEventTarget(event).closest('[data-react-aria-top-layer]')) { return false; } } diff --git a/packages/@react-aria/interactions/src/useLongPress.ts b/packages/@react-aria/interactions/src/useLongPress.ts index 1f910e487f5..17d40c096d5 100644 --- a/packages/@react-aria/interactions/src/useLongPress.ts +++ b/packages/@react-aria/interactions/src/useLongPress.ts @@ -11,7 +11,7 @@ */ import {DOMAttributes, FocusableElement, LongPressEvent} from '@react-types/shared'; -import {focusWithoutScrolling, getOwnerDocument, mergeProps, useDescription, useGlobalListeners} from '@react-aria/utils'; +import {focusWithoutScrolling, getEventTarget, getOwnerDocument, mergeProps, useDescription, useGlobalListeners} from '@react-aria/utils'; import {usePress} from './usePress'; import {useRef} from 'react'; @@ -80,11 +80,11 @@ export function useLongPress(props: LongPressProps): LongPressResult { timeRef.current = setTimeout(() => { // Prevent other usePress handlers from also handling this event. - e.target.dispatchEvent(new PointerEvent('pointercancel', {bubbles: true})); + getEventTarget(e).dispatchEvent(new PointerEvent('pointercancel', {bubbles: true})); // Ensure target is focused. On touch devices, browsers typically focus on pointer up. - if (getOwnerDocument(e.target).activeElement !== e.target) { - focusWithoutScrolling(e.target as FocusableElement); + if (getOwnerDocument(getEventTarget(e)).activeElement !== getEventTarget(e)) { + focusWithoutScrolling(getEventTarget(e) as FocusableElement); } if (onLongPress) { @@ -102,12 +102,12 @@ export function useLongPress(props: LongPressProps): LongPressResult { e.preventDefault(); }; - addGlobalListener(e.target, 'contextmenu', onContextMenu, {once: true}); + addGlobalListener(getEventTarget(e), 'contextmenu', onContextMenu, {once: true}); addGlobalListener(window, 'pointerup', () => { // If no contextmenu event is fired quickly after pointerup, remove the handler // so future context menu events outside a long press are not prevented. setTimeout(() => { - removeGlobalListener(e.target, 'contextmenu', onContextMenu); + removeGlobalListener(getEventTarget(e), 'contextmenu', onContextMenu); }, 30); }, {once: true}); } diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 7b918707908..ee58175102b 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -450,7 +450,7 @@ export function usePress(props: PressHookProps): PressResult { return; } - if (state.target && nodeContains(state.target, e.target as Element) && state.pointerType != null) { + if (state.target && nodeContains(state.target, getEventTarget(e) 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 { @@ -618,7 +618,7 @@ export function usePress(props: PressHookProps): PressResult { if (e.button === 0) { if (preventFocusOnPress) { - let dispose = preventFocus(e.target as FocusableElement); + let dispose = preventFocus(getEventTarget(e) as FocusableElement); if (dispose) { state.disposables.push(dispose); } @@ -692,7 +692,7 @@ export function usePress(props: PressHookProps): PressResult { } if (preventFocusOnPress) { - let dispose = preventFocus(e.target as FocusableElement); + let dispose = preventFocus(getEventTarget(e) as FocusableElement); if (dispose) { state.disposables.push(dispose); } diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index 46321a1d4a8..de3bcff60fc 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getOwnerWindow, isFocusable, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getEventTarget, getOwnerWindow, isFocusable, useLayoutEffect} from '@react-aria/utils'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; // Turn a native event into a React synthetic event. @@ -55,14 +55,14 @@ export function useSyntheticBlurEvent(onBlur: // MutationObserver to watch for the disabled attribute, and dispatch these events ourselves. // For browsers that do, focusout fires before the MutationObserver, so onBlur should not fire twice. if ( - e.target instanceof HTMLButtonElement || - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - e.target instanceof HTMLSelectElement + getEventTarget(e) instanceof HTMLButtonElement || + getEventTarget(e) instanceof HTMLInputElement || + getEventTarget(e) instanceof HTMLTextAreaElement || + getEventTarget(e) instanceof HTMLSelectElement ) { stateRef.current.isFocused = true; - let target = e.target; + let target = getEventTarget(e); let onBlurHandler: EventListenerOrEventListenerObject | null = (e) => { stateRef.current.isFocused = false; @@ -117,13 +117,13 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un ignoreFocusEvent = true; let isRefocusing = false; let onBlur = (e: FocusEvent) => { - if (e.target === activeElement || isRefocusing) { + if (getEventTarget(e) === activeElement || isRefocusing) { e.stopImmediatePropagation(); } }; let onFocusOut = (e: FocusEvent) => { - if (e.target === activeElement || isRefocusing) { + if (getEventTarget(e) === activeElement || isRefocusing) { e.stopImmediatePropagation(); // If there was no focusable ancestor, we don't expect a focus event. @@ -137,13 +137,13 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un }; let onFocus = (e: FocusEvent) => { - if (e.target === target || isRefocusing) { + if (getEventTarget(e) === target || isRefocusing) { e.stopImmediatePropagation(); } }; let onFocusIn = (e: FocusEvent) => { - if (e.target === target || isRefocusing) { + if (getEventTarget(e) === target || isRefocusing) { e.stopImmediatePropagation(); if (!isRefocusing) { diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts index 692d434981b..9f52cc70864 100644 --- a/packages/@react-aria/landmark/src/useLandmark.ts +++ b/packages/@react-aria/landmark/src/useLandmark.ts @@ -11,7 +11,7 @@ */ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {nodeContains, useLayoutEffect} from '@react-aria/utils'; +import {getEventTarget, nodeContains, useLayoutEffect} from '@react-aria/utils'; import {useCallback, useEffect, useState} from 'react'; import {useSyncExternalStore} from 'use-sync-external-store/shim/index.js'; @@ -315,7 +315,7 @@ class LandmarkManager implements LandmarkManagerApi { private f6Handler(e: KeyboardEvent) { if (e.key === 'F6') { // If alt key pressed, focus main landmark, otherwise navigate forward or backward based on shift key. - let handled = e.altKey ? this.focusMain() : this.navigate(e.target as FocusableElement, e.shiftKey); + let handled = e.altKey ? this.focusMain() : this.navigate(getEventTarget(e) as FocusableElement, e.shiftKey); if (handled) { e.preventDefault(); e.stopPropagation(); @@ -365,9 +365,9 @@ class LandmarkManager implements LandmarkManagerApi { * Lets the last focused landmark know it was blurred if something else is focused. */ private focusinHandler(e: FocusEvent) { - let currentLandmark = this.closestLandmark(e.target as FocusableElement); - if (currentLandmark && currentLandmark.ref.current !== e.target) { - this.updateLandmark({ref: currentLandmark.ref, lastFocused: e.target as FocusableElement}); + let currentLandmark = this.closestLandmark(getEventTarget(e) as FocusableElement); + if (currentLandmark && currentLandmark.ref.current !== getEventTarget(e)) { + this.updateLandmark({ref: currentLandmark.ref, lastFocused: getEventTarget(e) as FocusableElement}); } let previousFocusedElement = e.relatedTarget as FocusableElement; if (previousFocusedElement) { @@ -382,7 +382,7 @@ class LandmarkManager implements LandmarkManagerApi { * Track if the focus is lost to the body. If it is, do cleanup on the landmark that last had focus. */ private focusoutHandler(e: FocusEvent) { - let previousFocusedElement = e.target as FocusableElement; + let previousFocusedElement = getEventTarget(e) as FocusableElement; let nextFocusedElement = e.relatedTarget; // the === document seems to be a jest thing for focus to go there on generic blur event such as landmark.blur(); // browsers appear to send focus instead to document.body and the relatedTarget is null when that happens diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 58881c84064..50add5a4183 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -11,7 +11,7 @@ */ import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents, RefObject} from '@react-types/shared'; -import {filterDOMProps, handleLinkClick, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, handleLinkClick, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; import {getItemCount} from '@react-stately/collections'; import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from '@react-aria/interactions'; import {menuData} from './utils'; @@ -208,7 +208,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re // drag, and release over an item (matching native behavior). if (e.pointerType === 'mouse') { if (!isPressedRef.current) { - (e.target as HTMLElement).click(); + (getEventTarget(e) as HTMLElement).click(); } } @@ -285,14 +285,14 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re switch (e.key) { case ' ': interaction.current = {pointerType: 'keyboard', key: ' '}; - (e.target as HTMLElement).click(); + (getEventTarget(e) as HTMLElement).click(); break; case 'Enter': interaction.current = {pointerType: 'keyboard', key: 'Enter'}; // Trigger click unless this is a link. Links trigger click natively. - if ((e.target as HTMLElement).tagName !== 'A') { - (e.target as HTMLElement).click(); + if ((getEventTarget(e) as HTMLElement).tagName !== 'A') { + (getEventTarget(e) as HTMLElement).click(); } break; default: diff --git a/packages/@react-aria/menu/src/useMenuTrigger.ts b/packages/@react-aria/menu/src/useMenuTrigger.ts index 2d2227276a3..142162e8769 100644 --- a/packages/@react-aria/menu/src/useMenuTrigger.ts +++ b/packages/@react-aria/menu/src/useMenuTrigger.ts @@ -13,7 +13,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaMenuOptions} from './useMenu'; import {FocusableElement, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, useId} from '@react-aria/utils'; +import {focusWithoutScrolling, getEventTarget, useId} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import {MenuTriggerState} from '@react-stately/menu'; @@ -118,7 +118,7 @@ export function useMenuTrigger(props: AriaMenuTriggerProps, state: MenuTrigge // For consistency with native, open the menu on mouse/key down, but touch up. if (e.pointerType !== 'touch' && e.pointerType !== 'keyboard' && !isDisabled) { // Ensure trigger has focus before opening the menu so it can be restored by FocusScope on close. - focusWithoutScrolling(e.target as FocusableElement); + focusWithoutScrolling(getEventTarget(e) as FocusableElement); // If opened with a screen reader, auto focus the first item. // Otherwise, the menu itself will be focused. @@ -128,7 +128,7 @@ export function useMenuTrigger(props: AriaMenuTriggerProps, state: MenuTrigge onPress(e) { if (e.pointerType === 'touch' && !isDisabled) { // Ensure trigger has focus before opening the menu so it can be restored by FocusScope on close. - focusWithoutScrolling(e.target as FocusableElement); + focusWithoutScrolling(getEventTarget(e) as FocusableElement); state.toggle(); } diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 14df02c2243..b72f504479c 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, getEventTarget, 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'; @@ -106,7 +106,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm switch (e.key) { case 'ArrowLeft': - if (direction === 'ltr' && nodeContains(e.currentTarget, e.target as Element)) { + if (direction === 'ltr' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { e.preventDefault(); e.stopPropagation(); onSubmenuClose(); @@ -116,7 +116,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm } break; case 'ArrowRight': - if (direction === 'rtl' && nodeContains(e.currentTarget, e.target as Element)) { + if (direction === 'rtl' && nodeContains(e.currentTarget, getEventTarget(e) 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 (nodeContains(submenuRef.current, e.target as Element)) { + if (nodeContains(submenuRef.current, getEventTarget(e) 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 && (nodeContains(parentMenuRef.current, e.target as HTMLElement) && e.target !== ref.current)) { + if (state.isOpen && (nodeContains(parentMenuRef.current, getEventTarget(e) as HTMLElement) && getEventTarget(e) !== ref.current)) { onSubmenuClose(); } }); diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 670d79d69e0..3a11d36c294 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -12,7 +12,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaNumberFieldProps} from '@react-types/numberfield'; -import {chain, filterDOMProps, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; +import {chain, filterDOMProps, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; import {DOMAttributes, GroupDOMAttributes, TextInputDOMProps, ValidationResult} from '@react-types/shared'; import { InputHTMLAttributes, @@ -264,7 +264,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt if (e.pointerType === 'mouse') { inputRef.current?.focus(); } else { - e.target.focus(); + getEventTarget(e).focus(); } }; diff --git a/packages/@react-aria/overlays/src/useCloseOnScroll.ts b/packages/@react-aria/overlays/src/useCloseOnScroll.ts index 64f54860947..fff810607f7 100644 --- a/packages/@react-aria/overlays/src/useCloseOnScroll.ts +++ b/packages/@react-aria/overlays/src/useCloseOnScroll.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {nodeContains} from '@react-aria/utils'; +import {getEventTarget, nodeContains} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect} from 'react'; @@ -38,7 +38,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { let onScroll = (e: Event) => { // Ignore if scrolling an scrollable region outside the trigger's tree. - let target = e.target; + let target = getEventTarget(e); // window is not a Node and doesn't have contain, but window contains everything if (!triggerRef.current || ((target instanceof Node) && !nodeContains(target, triggerRef.current))) { return; @@ -47,7 +47,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { // Ignore scroll events on any input or textarea as the cursor position can cause it to scroll // such as in a combobox. Clicking the dropdown button places focus on the input, and if the // text inside the input extends beyond the 'end', then it will scroll so the cursor is visible at the end. - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + if (getEventTarget(e) instanceof HTMLInputElement || getEventTarget(e) instanceof HTMLTextAreaElement) { return; } diff --git a/packages/@react-aria/overlays/src/useOverlay.ts b/packages/@react-aria/overlays/src/useOverlay.ts index 8fdcfa39410..6a5ce8d2ad4 100644 --- a/packages/@react-aria/overlays/src/useOverlay.ts +++ b/packages/@react-aria/overlays/src/useOverlay.ts @@ -11,6 +11,7 @@ */ import {DOMAttributes, RefObject} from '@react-types/shared'; +import {getEventTarget} from '@react-aria/utils'; import {isElementInChildOfActiveScope} from '@react-aria/focus'; import {useEffect} from 'react'; import {useFocusWithin, useInteractOutside} from '@react-aria/interactions'; @@ -91,7 +92,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) { + if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(getEventTarget(e) as Element)) { if (visibleOverlays[visibleOverlays.length - 1] === ref) { e.stopPropagation(); e.preventDefault(); @@ -100,7 +101,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) { + if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(getEventTarget(e) as Element)) { if (visibleOverlays[visibleOverlays.length - 1] === ref) { e.stopPropagation(); e.preventDefault(); @@ -145,7 +146,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { // fixes a firefox issue that starts text selection https://bugzilla.mozilla.org/show_bug.cgi?id=1675846 - if (e.target === e.currentTarget) { + if (getEventTarget(e) === e.currentTarget) { e.preventDefault(); } }; diff --git a/packages/@react-aria/overlays/src/usePreventScroll.ts b/packages/@react-aria/overlays/src/usePreventScroll.ts index 6c4b7ee5772..b66e66baf1d 100644 --- a/packages/@react-aria/overlays/src/usePreventScroll.ts +++ b/packages/@react-aria/overlays/src/usePreventScroll.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils'; +import {chain, getEventTarget, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils'; interface PreventScrollOptions { /** Whether the scroll lock is disabled. */ @@ -96,7 +96,7 @@ function preventScrollMobileSafari() { let allowTouchMove = false; let onTouchStart = (e: TouchEvent) => { // Store the nearest scrollable parent element from the element that the user touched. - let target = e.target as Element; + let target = getEventTarget(e) as Element; scrollable = isScrollable(target) ? target : getScrollParent(target, true); allowTouchMove = false; @@ -154,7 +154,7 @@ function preventScrollMobileSafari() { }; let onBlur = (e: FocusEvent) => { - let target = e.target as HTMLElement; + let target = getEventTarget(e) as HTMLElement; let relatedTarget = e.relatedTarget as HTMLElement | null; if (relatedTarget && willOpenKeyboard(relatedTarget)) { // Focus without scrolling the whole page, and then scroll into view manually. diff --git a/packages/@react-aria/radio/src/useRadioGroup.ts b/packages/@react-aria/radio/src/useRadioGroup.ts index df09b13fe1d..e3733144b15 100644 --- a/packages/@react-aria/radio/src/useRadioGroup.ts +++ b/packages/@react-aria/radio/src/useRadioGroup.ts @@ -12,7 +12,7 @@ import {AriaRadioGroupProps} from '@react-types/radio'; import {DOMAttributes, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, getOwnerWindow, mergeProps, useId} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, getOwnerWindow, mergeProps, useId} from '@react-aria/utils'; import {getFocusableTreeWalker} from '@react-aria/focus'; import {radioGroupData} from './utils'; import {RadioGroupState} from '@react-stately/radio'; @@ -103,7 +103,7 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState } e.preventDefault(); let walker = getFocusableTreeWalker(e.currentTarget, { - from: e.target, + from: getEventTarget(e), accept: (node) => node instanceof getOwnerWindow(node).HTMLInputElement && node.type === 'radio' }); let nextElem; diff --git a/packages/@react-aria/select/src/HiddenSelect.tsx b/packages/@react-aria/select/src/HiddenSelect.tsx index 61e9455b571..27b76c68025 100644 --- a/packages/@react-aria/select/src/HiddenSelect.tsx +++ b/packages/@react-aria/select/src/HiddenSelect.tsx @@ -11,11 +11,11 @@ */ import {FocusableElement, Key, RefObject} from '@react-types/shared'; +import {getEventTarget, useFormReset} from '@react-aria/utils'; import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'react'; import {selectData} from './useSelect'; import {SelectionMode} from '@react-types/select'; import {SelectState} from '@react-stately/select'; -import {useFormReset} from '@react-aria/utils'; import {useFormValidation} from '@react-aria/form'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; @@ -92,9 +92,9 @@ export function useHiddenSelect(props: Ar let setValue = state.setValue; let onChange = useCallback((e: React.ChangeEvent) => { - if (e.target.multiple) { + if (getEventTarget(e).multiple) { setValue(Array.from( - e.target.selectedOptions, + getEventTarget(e).selectedOptions, (option) => option.value ) as any); } else { diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 39e5dd7fe45..fd4dfcc2e06 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, getEventTarget, 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 || !nodeContains(ref.current, e.target as Element)) { + if (!ref.current || !nodeContains(ref.current, getEventTarget(e) as Element)) { return; } @@ -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 (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { manager.setFocused(false); } @@ -345,7 +345,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } // Focus events can bubble through portals. Ignore these events. - if (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -560,7 +560,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions onBlur, onMouseDown(e) { // Ignore events that bubbled through portals. - if (scrollRef.current === e.target) { + if (scrollRef.current === getEventTarget(e)) { // Prevent focus going to the collection when clicking on the scrollbar. e.preventDefault(); } diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index cd6107a769b..6ec4f6a4ab6 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils'; +import {chain, getEventTarget, isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils'; import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared'; import {focusSafely, PressHookProps, useLongPress, usePress} from '@react-aria/interactions'; import {getCollectionId, isNonContiguousSelectionModifier} from './utils'; @@ -188,7 +188,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte itemProps = { tabIndex: key === manager.focusedKey ? 0 : -1, onFocus(e) { - if (e.target === ref.current) { + if (getEventTarget(e) === ref.current) { manager.setFocusedKey(key); } } diff --git a/packages/@react-aria/selection/src/useTypeSelect.ts b/packages/@react-aria/selection/src/useTypeSelect.ts index 2be01de8b3b..5d40d747d7f 100644 --- a/packages/@react-aria/selection/src/useTypeSelect.ts +++ b/packages/@react-aria/selection/src/useTypeSelect.ts @@ -11,9 +11,9 @@ */ import {DOMAttributes, Key, KeyboardDelegate} from '@react-types/shared'; +import {getEventTarget, nodeContains} from '@react-aria/utils'; 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. @@ -54,7 +54,7 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { let onKeyDown = (e: KeyboardEvent) => { let character = getStringForKey(e.key); - if (!character || e.ctrlKey || e.metaKey || !nodeContains(e.currentTarget, e.target as HTMLElement) || (state.search.length === 0 && character === ' ')) { + if (!character || e.ctrlKey || e.metaKey || !nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement) || (state.search.length === 0 && character === ' ')) { return; } diff --git a/packages/@react-aria/slider/src/useSliderThumb.ts b/packages/@react-aria/slider/src/useSliderThumb.ts index 5a609e4f78b..28f61055999 100644 --- a/packages/@react-aria/slider/src/useSliderThumb.ts +++ b/packages/@react-aria/slider/src/useSliderThumb.ts @@ -1,5 +1,5 @@ import {AriaSliderThumbProps} from '@react-types/slider'; -import {clamp, focusWithoutScrolling, mergeProps, useFormReset, useGlobalListeners} from '@react-aria/utils'; +import {clamp, focusWithoutScrolling, getEventTarget, mergeProps, useFormReset, useGlobalListeners} from '@react-aria/utils'; import {DOMAttributes, RefObject} from '@react-types/shared'; import {getSliderThumbId, sliderData} from './utils'; import React, {ChangeEvent, InputHTMLAttributes, LabelHTMLAttributes, useCallback, useEffect, useRef} from 'react'; @@ -255,7 +255,7 @@ export function useSliderThumb( 'aria-describedby': [data['aria-describedby'], opts['aria-describedby']].filter(Boolean).join(' '), 'aria-details': [data['aria-details'], opts['aria-details']].filter(Boolean).join(' '), onChange: (e: ChangeEvent) => { - state.setThumbValue(index, parseFloat(e.target.value)); + state.setThumbValue(index, parseFloat(getEventTarget(e).value)); } }), thumbProps: { diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 7d02274c946..6506d24396d 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -15,10 +15,10 @@ import {ColumnSize} from '@react-types/table'; import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared'; import {focusSafely, useInteractionModality, useKeyboard, useMove, usePress} from '@react-aria/interactions'; import {getColumnHeaderId} from './utils'; -import {GridNode} from '@react-types/grid'; +import {getEventTarget, mergeProps, useDescription, useEffectEvent, useId} from '@react-aria/utils'; // @ts-ignore +import {GridNode} from '@react-types/grid'; import intlMessages from '../intl/*.json'; -import {mergeProps, useDescription, useEffectEvent, useId} from '@react-aria/utils'; import {TableColumnResizeState} from '@react-stately/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; @@ -214,7 +214,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let onChange = (e: ChangeEvent) => { let currentWidth = state.getColumnWidth(item.key); - let nextValue = parseFloat(e.target.value); + let nextValue = parseFloat(getEventTarget(e).value); if (nextValue > currentWidth) { nextValue = currentWidth + 10; diff --git a/packages/@react-aria/textfield/src/useFormattedTextField.ts b/packages/@react-aria/textfield/src/useFormattedTextField.ts index e0d866fd016..1941f7f1f72 100644 --- a/packages/@react-aria/textfield/src/useFormattedTextField.ts +++ b/packages/@react-aria/textfield/src/useFormattedTextField.ts @@ -11,7 +11,7 @@ */ import {AriaTextFieldProps} from '@react-types/textfield'; -import {mergeProps, useEffectEvent} from '@react-aria/utils'; +import {getEventTarget, mergeProps, useEffectEvent} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {TextFieldAria, useTextField} from './useTextField'; import {useEffect, useRef} from 'react'; @@ -109,9 +109,9 @@ export function useFormattedTextField(props: AriaTextFieldProps, state: Formatte let onBeforeInput = !supportsNativeBeforeInputEvent() ? e => { let nextValue = - e.target.value.slice(0, e.target.selectionStart) + + getEventTarget(e).value.slice(0, getEventTarget(e).selectionStart) + e.data + - e.target.value.slice(e.target.selectionEnd); + getEventTarget(e).value.slice(getEventTarget(e).selectionEnd); if (!state.validate(nextValue)) { e.preventDefault(); diff --git a/packages/@react-aria/textfield/src/useTextField.ts b/packages/@react-aria/textfield/src/useTextField.ts index 18f2942a90c..e82f0293238 100644 --- a/packages/@react-aria/textfield/src/useTextField.ts +++ b/packages/@react-aria/textfield/src/useTextField.ts @@ -12,7 +12,7 @@ import {AriaTextFieldProps} from '@react-types/textfield'; import {DOMAttributes, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useFormReset} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, mergeProps, useFormReset} from '@react-aria/utils'; import React, { ChangeEvent, HTMLAttributes, @@ -163,7 +163,7 @@ export function useTextField) => setValue(e.target.value), + onChange: (e: ChangeEvent) => setValue(getEventTarget(e).value), autoComplete: props.autoComplete, autoCapitalize: props.autoCapitalize, maxLength: props.maxLength, diff --git a/packages/@react-aria/toast/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts index 1c6c3dbaf84..9a0a816635e 100644 --- a/packages/@react-aria/toast/src/useToastRegion.ts +++ b/packages/@react-aria/toast/src/useToastRegion.ts @@ -11,7 +11,7 @@ */ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, mergeProps, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getEventTarget, mergeProps, useLayoutEffect} from '@react-aria/utils'; import {getInteractionModality, useFocusWithin, useHover} from '@react-aria/interactions'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -190,7 +190,7 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState // listen to focus events separate from focuswithin because that will only fire once // and we need to follow all focus changes onFocus: (e) => { - let target = e.target.closest('[role="alertdialog"]'); + let target = getEventTarget(e).closest('[role="alertdialog"]'); focusedToast.current = toasts.current.findIndex(t => t === target); }, onBlur: () => { diff --git a/packages/@react-aria/toggle/src/useToggle.ts b/packages/@react-aria/toggle/src/useToggle.ts index 788c1b5e00f..af76ccbecfd 100644 --- a/packages/@react-aria/toggle/src/useToggle.ts +++ b/packages/@react-aria/toggle/src/useToggle.ts @@ -11,7 +11,7 @@ */ import {AriaToggleProps} from '@react-types/checkbox'; -import {filterDOMProps, mergeProps, useFormReset} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, mergeProps, useFormReset} from '@react-aria/utils'; import {InputHTMLAttributes, LabelHTMLAttributes} from 'react'; import {RefObject} from '@react-types/shared'; import {ToggleState} from '@react-stately/toggle'; @@ -61,7 +61,7 @@ export function useToggle(props: AriaToggleProps, state: ToggleState, ref: RefOb // since we spread props on label, onChange will end up there as well as in here. // so we have to stop propagation at the lowest level that we care about e.stopPropagation(); - state.setSelected(e.target.checked); + state.setSelected(getEventTarget(e).checked); }; let hasChildren = children != null; diff --git a/packages/@react-aria/toolbar/src/useToolbar.ts b/packages/@react-aria/toolbar/src/useToolbar.ts index 7331973d8f9..3908c26ac89 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, nodeContains, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, 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 (!nodeContains(e.currentTarget, e.target as HTMLElement)) { + if (!nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement)) { return; } if ( @@ -102,7 +102,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject(null); const onBlur = (e) => { if (!nodeContains(e.currentTarget, e.relatedTarget) && !lastFocused.current) { - lastFocused.current = e.target; + lastFocused.current = getEventTarget(e); } }; @@ -110,7 +110,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject { - if (lastFocused.current && !nodeContains(e.currentTarget, e.relatedTarget) && nodeContains(ref.current, e.target)) { + if (lastFocused.current && !nodeContains(e.currentTarget, e.relatedTarget) && nodeContains(ref.current, getEventTarget(e))) { lastFocused.current?.focus(); lastFocused.current = null; } diff --git a/packages/@react-aria/utils/src/runAfterTransition.ts b/packages/@react-aria/utils/src/runAfterTransition.ts index 3004d2313df..392aee8f9e3 100644 --- a/packages/@react-aria/utils/src/runAfterTransition.ts +++ b/packages/@react-aria/utils/src/runAfterTransition.ts @@ -16,6 +16,7 @@ // bugs, e.g. Chrome sometimes fires both transitionend and transitioncancel rather // than one or the other. So we need to track what's actually transitioning so that // we can ignore these duplicate events. +import {getEventTarget} from './shadowdom/DOMFunctions'; let transitionsByElement = new Map>(); // A list of callbacks to call once there are no transitioning elements. @@ -31,19 +32,19 @@ function setupGlobalEvents() { } let onTransitionStart = (e: Event) => { - if (!isTransitionEvent(e) || !e.target) { + if (!isTransitionEvent(e) || !getEventTarget(e)) { return; } // Add the transitioning property to the list for this element. - let transitions = transitionsByElement.get(e.target); + let transitions = transitionsByElement.get(getEventTarget(e)); if (!transitions) { transitions = new Set(); - transitionsByElement.set(e.target, transitions); + transitionsByElement.set(getEventTarget(e), transitions); // The transitioncancel event must be registered on the element itself, rather than as a global // event. This enables us to handle when the node is deleted from the document while it is transitioning. // In that case, the cancel event would have nowhere to bubble to so we need to handle it directly. - e.target.addEventListener('transitioncancel', onTransitionEnd, { + getEventTarget(e).addEventListener('transitioncancel', onTransitionEnd, { once: true }); } @@ -52,11 +53,11 @@ function setupGlobalEvents() { }; let onTransitionEnd = (e: Event) => { - if (!isTransitionEvent(e) || !e.target) { + if (!isTransitionEvent(e) || !getEventTarget(e)) { return; } // Remove property from list of transitioning properties. - let properties = transitionsByElement.get(e.target); + let properties = transitionsByElement.get(getEventTarget(e)); if (!properties) { return; } @@ -65,8 +66,8 @@ function setupGlobalEvents() { // If empty, remove transitioncancel event, and remove the element from the list of transitioning elements. if (properties.size === 0) { - e.target.removeEventListener('transitioncancel', onTransitionEnd); - transitionsByElement.delete(e.target); + getEventTarget(e).removeEventListener('transitioncancel', onTransitionEnd); + transitionsByElement.delete(getEventTarget(e)); } // If no transitioning elements, call all of the queued callbacks. diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index bb69beb6b08..1d66b30bac5 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -1,5 +1,5 @@ // Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 -/* eslint-disable rsp-rules/no-non-shadow-contains */ +/* eslint-disable rsp-rules/no-non-shadow-contains, rsp-rules/safe-event-target */ import {isShadowRoot} from '../domHelpers'; import {shadowDOM} from '@react-stately/flags'; @@ -61,7 +61,10 @@ export const getActiveElement = (doc: Document = document): Element | null => { /** * ShadowDOM safe version of event.target. */ -export function getEventTarget(event: T): Element { +export function getEventTarget(event: T | null | undefined): Element | null { + if (!event) { + return null; + } if (shadowDOM() && (event.target as HTMLElement).shadowRoot) { if (event.composedPath) { return event.composedPath()[0] as Element; diff --git a/packages/@react-aria/utils/src/useDrag1D.ts b/packages/@react-aria/utils/src/useDrag1D.ts index 41fe28abac8..e5c4f568cca 100644 --- a/packages/@react-aria/utils/src/useDrag1D.ts +++ b/packages/@react-aria/utils/src/useDrag1D.ts @@ -12,8 +12,8 @@ /* eslint-disable rulesdir/pure-render */ +import {getEventTarget, nodeContains} from './shadowdom/DOMFunctions'; import {getOffset} from './getOffset'; -import {nodeContains} from './shadowdom/DOMFunctions'; import {Orientation} from '@react-types/shared'; import React, {HTMLAttributes, MutableRefObject, useRef} from 'react'; @@ -81,7 +81,7 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes { }; let onMouseUp = (e: MouseEvent) => { - const target = e.target as HTMLElement; + const target = getEventTarget(e) as HTMLElement; dragging.current = false; let nextOffset = getNextOffset(e); if (handlers.current.onDrag) { diff --git a/packages/@react-aria/utils/src/useViewportSize.ts b/packages/@react-aria/utils/src/useViewportSize.ts index ed6f6f765e5..13b9cce9d64 100644 --- a/packages/@react-aria/utils/src/useViewportSize.ts +++ b/packages/@react-aria/utils/src/useViewportSize.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {getEventTarget} from './shadowdom/DOMFunctions'; import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; import {willOpenKeyboard} from './keyboard'; @@ -53,7 +54,7 @@ export function useViewportSize(): ViewportSize { return; } - if (willOpenKeyboard(e.target as Element)) { + if (willOpenKeyboard(getEventTarget(e) as Element)) { // Wait one frame to see if a new element gets focused. frame = requestAnimationFrame(() => { if (!document.activeElement || !willOpenKeyboard(document.activeElement)) { diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index b57efc4f19e..ba6c460104f 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -12,6 +12,7 @@ // @ts-ignore import {flushSync} from 'react-dom'; +import {getEventTarget, useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {getScrollLeft} from './utils'; import React, { CSSProperties, @@ -25,7 +26,6 @@ import React, { useState } from 'react'; import {Rect, Size} from '@react-stately/virtualizer'; -import {useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; interface ScrollViewProps extends HTMLAttributes { @@ -87,7 +87,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { - if (e.target !== e.currentTarget) { + if (getEventTarget(e) !== e.currentTarget) { return; } diff --git a/packages/@react-spectrum/menu/src/useCloseOnScroll.ts b/packages/@react-spectrum/menu/src/useCloseOnScroll.ts index 64f54860947..fff810607f7 100644 --- a/packages/@react-spectrum/menu/src/useCloseOnScroll.ts +++ b/packages/@react-spectrum/menu/src/useCloseOnScroll.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {nodeContains} from '@react-aria/utils'; +import {getEventTarget, nodeContains} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect} from 'react'; @@ -38,7 +38,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { let onScroll = (e: Event) => { // Ignore if scrolling an scrollable region outside the trigger's tree. - let target = e.target; + let target = getEventTarget(e); // window is not a Node and doesn't have contain, but window contains everything if (!triggerRef.current || ((target instanceof Node) && !nodeContains(target, triggerRef.current))) { return; @@ -47,7 +47,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { // Ignore scroll events on any input or textarea as the cursor position can cause it to scroll // such as in a combobox. Clicking the dropdown button places focus on the input, and if the // text inside the input extends beyond the 'end', then it will scroll so the cursor is visible at the end. - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + if (getEventTarget(e) instanceof HTMLInputElement || getEventTarget(e) instanceof HTMLTextAreaElement) { return; } diff --git a/packages/@react-spectrum/s2/src/Field.tsx b/packages/@react-spectrum/s2/src/Field.tsx index 38c9dfda58e..efe35e3593b 100644 --- a/packages/@react-spectrum/s2/src/Field.tsx +++ b/packages/@react-spectrum/s2/src/Field.tsx @@ -19,13 +19,13 @@ import {composeRenderProps, FieldError, FieldErrorProps, Group, GroupProps, Labe import {ContextualHelpContext} from './ContextualHelp'; import {control, controlFont, fieldInput, fieldLabel, StyleProps, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {ForwardedRef, forwardRef, ReactNode} from 'react'; -import {IconContext} from './Icon'; +import {getEventTarget, useId} from '@react-aria/utils'; // @ts-ignore +import {IconContext} from './Icon'; import intlMessages from '../intl/*.json'; import {mergeStyles} from '../style/runtime'; import {StyleString} from '../style/types'; import {useDOMRef} from '@react-spectrum/utils'; -import {useId} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; interface FieldLabelProps extends Omit, StyleProps { @@ -207,13 +207,13 @@ export const FieldGroup = forwardRef(function FieldGroup(props: FieldGroupProps, {...otherProps} onPointerDown={(e) => { // Forward focus to input element when clicking on a non-interactive child (e.g. icon or padding) - if (e.pointerType === 'mouse' && !(e.target as Element).closest('button,input,textarea,[role="button"]')) { + if (e.pointerType === 'mouse' && !(getEventTarget(e) as Element).closest('button,input,textarea,[role="button"]')) { e.preventDefault(); (e.currentTarget.querySelector('input, textarea') as HTMLElement)?.focus(); } }} onTouchEnd={e => { - let target = e.target as HTMLElement; + let target = getEventTarget(e) as HTMLElement; if (!target.isContentEditable && !target.closest('button,input,textarea,[role="button"]')) { e.preventDefault(); (e.currentTarget.querySelector('input, textarea') as HTMLElement)?.focus(); diff --git a/packages/@react-spectrum/s2/src/Toast.tsx b/packages/@react-spectrum/s2/src/Toast.tsx index ad54fe3dc63..b07b0419593 100644 --- a/packages/@react-spectrum/s2/src/Toast.tsx +++ b/packages/@react-spectrum/s2/src/Toast.tsx @@ -19,7 +19,7 @@ import Chevron from '../s2wf-icons/S2_Icon_ChevronDown_20_N.svg'; import {CloseButton} from './CloseButton'; import {createContext, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {DOMProps} from '@react-types/shared'; -import {filterDOMProps, isWebKit, useEvent} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, isWebKit, useEvent} from '@react-aria/utils'; import {flushSync} from 'react-dom'; import {focusRing, style} from '../style' with {type: 'macro'}; import {FocusScope, useModalOverlay} from 'react-aria'; @@ -446,7 +446,7 @@ function SpectrumToastList({placement, align, reduceMotion}) { let toastListRef = useRef(null); useEvent(toastListRef, 'click', (e) => { // Have to check if this is a button because stopPropagation in react events doesn't affect native events. - if (!isExpanded && !(e.target as Element)?.closest('button')) { + if (!isExpanded && !(getEventTarget(e) as Element)?.closest('button')) { toggleExpanded(); } }); diff --git a/packages/dev/eslint-plugin-rsp-rules/index.js b/packages/dev/eslint-plugin-rsp-rules/index.js index 4bb1265f0fc..03339ed04d0 100644 --- a/packages/dev/eslint-plugin-rsp-rules/index.js +++ b/packages/dev/eslint-plugin-rsp-rules/index.js @@ -14,6 +14,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 safeEventTarget from './rules/safe-event-target.js'; import sortImports from './rules/sort-imports.js'; const rules = { @@ -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, + 'safe-event-target': safeEventTarget }; const meta = { diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/safe-event-target.js b/packages/dev/eslint-plugin-rsp-rules/rules/safe-event-target.js new file mode 100644 index 00000000000..ddd9cac115d --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/rules/safe-event-target.js @@ -0,0 +1,132 @@ +/* + * 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 event.target in favor of getEventTarget for shadow DOM compatibility', + recommended: true + }, + fixable: 'code', + messages: { + useGetEventTarget: 'Use getEventTarget() instead of .target for shadow DOM compatibility.' + } + }, + create: (context) => { + let hasGetEventTargetImport = false; + let getEventTargetLocalName = 'getEventTarget'; + 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 getEventTarget is already imported + const hasGetEventTarget = node.specifiers.some( + spec => spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'getEventTarget' + ); + if (hasGetEventTarget) { + hasGetEventTargetImport = true; + const getEventTargetSpec = node.specifiers.find( + spec => spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'getEventTarget' + ); + getEventTargetLocalName = getEventTargetSpec.local.name; + } + } + }, + + // Detect .target property access + ['MemberExpression[property.name=\'target\']'](node) { + // Skip if it's already a getEventTarget call result + if (node.object.type === 'CallExpression' && + node.object.callee.type === 'Identifier' && + node.object.callee.name === getEventTargetLocalName) { + return; + } + + // Only match common event parameter names + const commonEventNames = /^(e|event|evt)$/i; + let isEventTarget = false; + + if (node.object.type === 'Identifier') { + // Check if the identifier matches common event names (e.target, event.target, evt.target) + isEventTarget = commonEventNames.test(node.object.name); + } + + // Skip if this doesn't look like an event target access + if (!isEventTarget) { + return; + } + + context.report({ + node, + messageId: 'useGetEventTarget', + fix: (fixer) => { + const fixes = []; + const sourceCode = context.sourceCode; + + // Get the event object (e.g., 'event' from 'event.target') + const eventText = sourceCode.getText(node.object); + + // Replace event.target with getEventTarget(event) + fixes.push(fixer.replaceText(node, `${getEventTargetLocalName}(${eventText})`)); + + // Add import if not present + if (!hasGetEventTargetImport) { + if (existingReactAriaUtilsImport) { + // Add getEventTarget to existing @react-aria/utils import + const specifiers = existingReactAriaUtilsImport.specifiers; + if (specifiers.length > 0) { + fixes.push(fixer.insertTextAfter( + sourceCode.getFirstToken(existingReactAriaUtilsImport, token => token.value === '{'), + 'getEventTarget, ' + )); + } + } 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 {getEventTarget} from \'@react-aria/utils\';'; + fixes.push(fixer.insertTextAfter(lastImport, importStatement)); + } else { + // No imports, add at the beginning + const importStatement = 'import {getEventTarget} from \'@react-aria/utils\';\n'; + fixes.push(fixer.insertTextBefore(programNode.body[0], importStatement)); + } + } + + // Mark as imported for subsequent fixes in the same file + hasGetEventTargetImport = true; + } + + return fixes; + } + }); + } + }; + } +}; + +export default plugin; diff --git a/packages/dev/eslint-plugin-rsp-rules/test/safe-event-target.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/safe-event-target.test-lint.js new file mode 100644 index 00000000000..1355ac75232 --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/test/safe-event-target.test-lint.js @@ -0,0 +1,129 @@ +/* + * 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 {RuleTester} from 'eslint'; +import safeEventTargetRule from '../rules/safe-event-target.js'; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2015, + sourceType: 'module' + } +}); + +// Throws error if the tests in ruleTester.run() do not pass +ruleTester.run( + 'safe-event-target', + safeEventTargetRule, + { + // 'valid' checks cases that should pass + valid: [ + { + code: `import {getEventTarget} from '@react-aria/utils'; +const target = getEventTarget(event);` + }, + { + code: `import {getEventTarget} from '@react-aria/utils'; +function handleClick(e) { + const target = getEventTarget(e); + console.log(target); +}` + }, + { + code: `function checkTarget(props) { + return props.target; +}` + }, + { + code: 'const value = target.target;' + }, + { + code: `function focusTarget(ref) { + ref.target.focus(); +}` + }, + { + code: `const link = {target: '_blank'}; +console.log(link.target);` + } + ], + // 'invalid' checks cases that should not pass + invalid: [ + { + code: `function handleClick(event) { + const target = event.target; +}`, + output: `import {getEventTarget} from '@react-aria/utils'; +function handleClick(event) { + const target = getEventTarget(event); +}`, + errors: 1 + }, + { + code: 'const element = e.target;', + output: `import {getEventTarget} from '@react-aria/utils'; +const element = getEventTarget(e);`, + errors: 1 + }, + { + code: `import {something} from '@react-aria/utils'; +function handleEvent(evt) { + console.log(evt.target); +}`, + output: `import {getEventTarget, something} from '@react-aria/utils'; +function handleEvent(evt) { + console.log(getEventTarget(evt)); +}`, + errors: 1 + }, + { + code: `import {getEventTarget} from '@react-aria/utils'; +function handleClick(event) { + const target = event.target; +}`, + output: `import {getEventTarget} from '@react-aria/utils'; +function handleClick(event) { + const target = getEventTarget(event); +}`, + errors: 1 + }, + { + code: `import React from 'react'; +const onClick = (e) => { + const target = e.target; + const value = e.target.value; +};`, + output: `import React from 'react'; +import {getEventTarget} from '@react-aria/utils'; +const onClick = (e) => { + const target = getEventTarget(e); + const value = getEventTarget(e).value; +};`, + errors: 2 + }, + { + code: `function onKeyDown(event) { + if (event.target instanceof HTMLElement) { + event.target.focus(); + } +}`, + output: `import {getEventTarget} from '@react-aria/utils'; +function onKeyDown(event) { + if (getEventTarget(event) instanceof HTMLElement) { + getEventTarget(event).focus(); + } +}`, + errors: 2 + } + ] + } +); diff --git a/packages/react-aria-components/src/DropZone.tsx b/packages/react-aria-components/src/DropZone.tsx index 298179b6ef9..44083258dc4 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, nodeContains, useLabels, useObjectRef, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, isFocusable, nodeContains, useLabels, useObjectRef, useSlotId} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import React, {createContext, ForwardedRef, forwardRef, useRef} from 'react'; @@ -116,7 +116,7 @@ export const DropZone = forwardRef(function DropZone(props: DropZoneProps, ref: slot={props.slot || undefined} ref={dropzoneRef} onClick={(e) => { - let target = e.target as HTMLElement | null; + let target = getEventTarget(e) as HTMLElement | null; while (target && nodeContains(dropzoneRef.current, target)) { if (isFocusable(target)) { break; diff --git a/packages/react-aria-components/src/FileTrigger.tsx b/packages/react-aria-components/src/FileTrigger.tsx index 979580dc7dd..3ddbfcb09f7 100644 --- a/packages/react-aria-components/src/FileTrigger.tsx +++ b/packages/react-aria-components/src/FileTrigger.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {filterDOMProps, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, useObjectRef} from '@react-aria/utils'; import {GlobalDOMAttributes} from '@react-types/shared'; import {Input} from './Input'; import {PressResponder} from '@react-aria/interactions'; @@ -69,7 +69,7 @@ export const FileTrigger = forwardRef(function FileTrigger(props: FileTriggerPro ref={inputRef} style={{display: 'none'}} accept={acceptedFileTypes?.toString()} - onChange={(e) => onSelect?.(e.target.files)} + onChange={(e) => onSelect?.(getEventTarget(e).files)} capture={defaultCamera} multiple={allowsMultiple} // @ts-expect-error diff --git a/packages/react-aria-components/src/HiddenDateInput.tsx b/packages/react-aria-components/src/HiddenDateInput.tsx index a7eeeb9c4d9..b6962a3a7c0 100644 --- a/packages/react-aria-components/src/HiddenDateInput.tsx +++ b/packages/react-aria-components/src/HiddenDateInput.tsx @@ -13,6 +13,7 @@ import {CalendarDate, CalendarDateTime, parseDate, parseDateTime, toCalendarDate, toCalendarDateTime, toLocalTimeZone} from '@internationalized/date'; import {DateFieldState, DatePickerState, DateSegmentType} from 'react-stately'; +import {getEventTarget} from '@react-aria/utils'; import React, {ReactNode} from 'react'; import {useVisuallyHidden} from 'react-aria'; @@ -105,7 +106,7 @@ export function useHiddenDateInput(props: HiddenDateInputProps, state: DateField step: inputStep, value: dateValue, onChange: (e) => { - let targetString = e.target.value.toString(); + let targetString = getEventTarget(e).value.toString(); if (targetString) { try { let targetValue: CalendarDateTime | CalendarDate = parseDateTime(targetString); diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 6eeac03c73e..337fa8dc8ae 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -31,7 +31,7 @@ import {DisabledBehavior, DragPreviewRenderer, Expandable, forwardRefType, Globa import {DragAndDropContext, DropIndicatorContext, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, Node, SelectionBehavior, TreeState, useTreeState} from 'react-stately'; -import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; @@ -305,8 +305,8 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne dropTargetDelegate: treeDropTargetDelegate, onDropActivate: (e) => { // Expand collapsed item when dragging over. For keyboard, allow collapsing. - if (e.target.type === 'item') { - let key = e.target.key; + if (getEventTarget(e).type === 'item') { + let key = getEventTarget(e).key; let item = state.collection.getItem(key); let isExpanded = expandedKeys !== 'all' && expandedKeys.has(key); if (item && item.hasChildNodes && (!isExpanded || dragAndDropHooks?.isVirtualDragging?.())) {