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..e06a3123776 100644 --- a/packages/@react-aria/actiongroup/src/useActionGroup.ts +++ b/packages/@react-aria/actiongroup/src/useActionGroup.ts @@ -13,10 +13,10 @@ 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 {KeyboardEventHandler, useState} from 'react'; import {ListState} from '@react-stately/list'; import {useLocale} from '@react-aria/i18n'; -import {useState} from 'react'; const BUTTON_GROUP_ROLES = { 'none': 'toolbar', @@ -47,8 +47,8 @@ export function useActionGroup(props: AriaActionGroupProps, state: ListSta let {direction} = useLocale(); let focusManager = createFocusManager(ref); let flipDirection = direction === 'rtl' && orientation === 'horizontal'; - let onKeyDown = (e) => { - if (!nodeContains(e.currentTarget, e.target)) { + let onKeyDown: KeyboardEventHandler = (e) => { + 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..f06f13de5c1 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 @@ -333,17 +333,18 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta state.highlightDate(date); } }, - onPointerDown(e) { + onPointerDown(e: PointerEvent) { // 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); + let target = getEventTarget(e); + if (target instanceof HTMLElement && 'releasePointerCapture' in target) { + if ('hasPointerCapture' in target) { + if (target.hasPointerCapture(e.pointerId)) { + target.releasePointerCapture(e.pointerId); } } else { - e.target.releasePointerCapture(e.pointerId); + (target as HTMLElement).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..7e7348d819d 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 {getEventTarget, isVirtualClick, isVirtualPointerEvent, useDescription, useGlobalListeners} from '@react-aria/utils'; import {globalDropEffect, setGlobalAllowedDropOperations, setGlobalDropEffect, useDragModality, writeToDataTransfer} from './utils'; // @ts-ignore 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); }); @@ -266,12 +266,12 @@ export function useDrag(options: DragOptions): DragResult { }; }, [state]); - let onPress = (e: PressEvent) => { - if (e.pointerType !== 'keyboard' && e.pointerType !== 'virtual') { + let onPress = (pressEvent: PressEvent) => { + if (pressEvent.pointerType !== 'keyboard' && pressEvent.pointerType !== 'virtual') { return; } - startDragging(e.target as HTMLElement); + startDragging(pressEvent.target 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..80a3c834899 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -11,12 +11,12 @@ */ 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 {FocusEventHandler, useCallback, useMemo} from 'react'; import {GridCollection} from '@react-types/grid'; import {GridKeyboardDelegate} from './GridKeyboardDelegate'; import {gridMap} from './utils'; import {GridState} from '@react-stately/grid'; -import {useCallback, useMemo} from 'react'; import {useCollator, useLocale} from '@react-aria/i18n'; import {useGridSelectionAnnouncement} from './useGridSelectionAnnouncement'; import {useHasTabbableChild} from '@react-aria/focus'; @@ -133,10 +133,10 @@ export function useGrid(props: GridProps, state: GridState { + let onFocus: FocusEventHandler = useCallback((e) => { 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..5a929275236 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(e ? getEventTarget(e) as Element : undefined); + const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(e ? getEventTarget(e) as Element : undefined).HTMLInputElement : HTMLInputElement; + const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(e ? getEventTarget(e) as Element : undefined).HTMLTextAreaElement : HTMLTextAreaElement; + const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(e ? getEventTarget(e) as Element : undefined).HTMLElement : HTMLElement; + const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(e ? getEventTarget(e) as Element : undefined).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..5a1d94c4eee 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) as Element)) { 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) as Element), '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..309899d9a92 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,15 @@ function isValidEvent(event, ref) { if (event.button > 0) { return false; } - if (event.target) { + let target = getEventTarget(event) as Element; + if (target) { // 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 = target.ownerDocument; + if (!ownerDocument || !nodeContains(ownerDocument.documentElement, target)) { 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 (target.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..3fc3bd20bd6 100644 --- a/packages/@react-aria/interactions/src/useLongPress.ts +++ b/packages/@react-aria/interactions/src/useLongPress.ts @@ -68,28 +68,28 @@ export function useLongPress(props: LongPressProps): LongPressResult { let {pressProps} = usePress({ isDisabled, - onPressStart(e) { - e.continuePropagation(); - if (e.pointerType === 'mouse' || e.pointerType === 'touch') { + onPressStart(pressEvent) { + pressEvent.continuePropagation(); + if (pressEvent.pointerType === 'mouse' || pressEvent.pointerType === 'touch') { if (onLongPressStart) { onLongPressStart({ - ...e, + ...pressEvent, type: 'longpressstart' }); } timeRef.current = setTimeout(() => { // Prevent other usePress handlers from also handling this event. - e.target.dispatchEvent(new PointerEvent('pointercancel', {bubbles: true})); + pressEvent.target.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(pressEvent.target).activeElement !== pressEvent.target) { + focusWithoutScrolling(pressEvent.target as FocusableElement); } if (onLongPress) { onLongPress({ - ...e, + ...pressEvent, type: 'longpress' }); } @@ -97,17 +97,17 @@ export function useLongPress(props: LongPressProps): LongPressResult { }, threshold); // Prevent context menu, which may be opened on long press on touch devices - if (e.pointerType === 'touch') { + if (pressEvent.pointerType === 'touch') { let onContextMenu = e => { e.preventDefault(); }; - addGlobalListener(e.target, 'contextmenu', onContextMenu, {once: true}); + addGlobalListener(pressEvent.target, '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(pressEvent.target, 'contextmenu', onContextMenu); }, 30); }, {once: true}); } diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index d3cca266f43..e49d961f89c 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -36,8 +36,8 @@ import {createSyntheticEvent, preventFocus, setEventTarget} from './utils'; import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; +import {type KeyboardEvent, KeyboardEventHandler, PointerEventHandler, MouseEvent as RMouseEvent, TouchEvent as RTouchEvent, UIEventHandler, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {PressResponderContext} from './context'; -import {MouseEvent as RMouseEvent, TouchEvent as RTouchEvent, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; export interface PressProps extends PressEvents { /** Whether the target is in a controlled press state (e.g. an overlay it triggers is open). */ @@ -72,7 +72,7 @@ interface PressState { isOverTarget: boolean, pointerType: PointerType | null, userSelect?: string, - metaKeyEvents?: Map, + metaKeyEvents?: Map, disposables: Array<() => void> } @@ -313,7 +313,7 @@ export function usePress(props: PressHookProps): PressResult { onClick?.(e); }, [isDisabled, onClick]); - let triggerSyntheticClick = useCallback((e: KeyboardEvent | TouchEvent, target: FocusableElement) => { + let triggerSyntheticClick = useCallback((e: KeyboardEvent | TouchEvent, target: FocusableElement) => { if (isDisabled) { return; } @@ -324,7 +324,7 @@ export function usePress(props: PressHookProps): PressResult { // https://html.spec.whatwg.org/#activation // https://html.spec.whatwg.org/#fire-a-synthetic-pointer-event if (onClick) { - let event = new MouseEvent('click', e); + let event = new MouseEvent('click', e as unknown as MouseEvent); setEventTarget(event, target); onClick(createSyntheticEvent(event)); } @@ -335,7 +335,7 @@ export function usePress(props: PressHookProps): PressResult { useLayoutEffect(() => { let state = ref.current; if (isElemKeyPressed) { - let onKeyUp = (e: KeyboardEvent) => { + let onKeyUp: KeyboardEventHandler = (e) => { if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) { if (shouldPreventDefaultKeyboard(getEventTarget(e), e.key)) { e.preventDefault(); @@ -369,7 +369,7 @@ export function usePress(props: PressHookProps): PressResult { let events = state.metaKeyEvents; state.metaKeyEvents = undefined; for (let event of events.values()) { - state.target?.dispatchEvent(new KeyboardEvent('keyup', event)); + state.target?.dispatchEvent(new KeyboardEvent('keyup', event as unknown as KeyboardEventInit)); } } }; @@ -377,7 +377,7 @@ export function usePress(props: PressHookProps): PressResult { // instead of the same element where the key down event occurred. Make it capturing so that it will trigger // before stopPropagation from useKeyboard on a child element may happen and thus we can still call triggerPress for the parent element. let originalTarget = state.target; - let pressUp = (e) => { + let pressUp: KeyboardEventHandler = (e) => { if (originalTarget && isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e)) && state.target) { triggerPressUpEvent(createEvent(state.target, e), 'keyboard'); } @@ -394,7 +394,7 @@ export function usePress(props: PressHookProps): PressResult { useLayoutEffect(() => { let state = ref.current; if (isPointerPressed === 'pointer') { - let onPointerUp = (e: PointerEvent) => { + let onPointerUp: PointerEventHandler = (e) => { if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) { if (nodeContains(state.target, getEventTarget(e)) && state.pointerType != null) { // Wait for onClick to fire onPress. This avoids browser issues when the DOM @@ -420,7 +420,7 @@ export function usePress(props: PressHookProps): PressResult { }, 80); // Use a capturing listener to track if a click occurred. // If stopPropagation is called it may never reach our handler. - addGlobalListener(e.currentTarget as Document, 'click', () => clicked = true, true); + addGlobalListener(e.currentTarget, 'click', () => clicked = true, true); state.disposables.push(() => clearTimeout(timeout)); } else { cancelEvent(e); @@ -435,10 +435,10 @@ export function usePress(props: PressHookProps): PressResult { cancelEvent(e); }; - addGlobalListener(getOwnerDocument(state.target), 'pointerup', onPointerUp, false); + addGlobalListener(getOwnerDocument(state.target), 'pointerup', onPointerUp as unknown as (this: Document, ev: PointerEvent) => any, false); addGlobalListener(getOwnerDocument(state.target), 'pointercancel', onPointerCancel, false); return () => { - removeGlobalListener(getOwnerDocument(state.target), 'pointerup', onPointerUp, false); + removeGlobalListener(getOwnerDocument(state.target), 'pointerup', onPointerUp as unknown as (this: Document, ev: PointerEvent) => any, false); removeGlobalListener(getOwnerDocument(state.target), 'pointercancel', onPointerCancel, false); }; } else if (isPointerPressed === 'mouse' && process.env.NODE_ENV === 'test') { @@ -453,7 +453,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 { @@ -468,7 +468,7 @@ export function usePress(props: PressHookProps): PressResult { removeGlobalListener(getOwnerDocument(state.target), 'mouseup', onMouseUp, false); }; } else if (isPointerPressed === 'touch' && process.env.NODE_ENV === 'test') { - let onScroll = (e: Event) => { + let onScroll: UIEventHandler = (e) => { if (state.isPressed && nodeContains(getEventTarget(e), state.target)) { cancelEvent({ currentTarget: state.target, @@ -480,9 +480,9 @@ export function usePress(props: PressHookProps): PressResult { } }; - addGlobalListener(getOwnerWindow(state.target), 'scroll', onScroll, true); + addGlobalListener(getOwnerWindow(state.target), 'scroll', onScroll as unknown as (this: Document, ev: Event) => any, true); return () => { - removeGlobalListener(getOwnerWindow(state.target), 'scroll', onScroll, true); + removeGlobalListener(getOwnerWindow(state.target), 'scroll', onScroll as unknown as (this: Document, ev: Event) => any, true); }; } }, [isPointerPressed, addGlobalListener, removeGlobalListener]); @@ -491,8 +491,8 @@ export function usePress(props: PressHookProps): PressResult { let state = ref.current; let pressProps: DOMAttributes = { onKeyDown(e) { - if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { - if (shouldPreventDefaultKeyboard(getEventTarget(e.nativeEvent), e.key)) { + if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && nodeContains(e.currentTarget, getEventTarget(e))) { + if (shouldPreventDefaultKeyboard(getEventTarget(e), e.key)) { e.preventDefault(); } @@ -527,7 +527,7 @@ export function usePress(props: PressHookProps): PressResult { } }, onClick(e) { - if (e && !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (e && !nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -566,7 +566,7 @@ export function usePress(props: PressHookProps): PressResult { if (typeof PointerEvent !== 'undefined') { pressProps.onPointerDown = (e) => { // Only handle left clicks, and ignore events that bubbled through portals. - if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -597,7 +597,7 @@ export function usePress(props: PressHookProps): PressResult { // Release pointer capture so that touch interactions can leave the original target. // This enables onPointerLeave and onPointerEnter to fire. - let target = getEventTarget(e.nativeEvent); + let target = getEventTarget(e); if ('releasePointerCapture' in target) { if ('hasPointerCapture' in target) { if (target.hasPointerCapture(e.pointerId)) { @@ -615,13 +615,13 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseDown = (e) => { - if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } 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); } @@ -633,7 +633,7 @@ export function usePress(props: PressHookProps): PressResult { pressProps.onPointerUp = (e) => { // iOS fires pointerup with zero width and height, so check the pointerType recorded during pointerdown. - if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent)) || state.pointerType === 'virtual') { + if (!nodeContains(e.currentTarget, getEventTarget(e)) || state.pointerType === 'virtual') { return; } @@ -660,7 +660,7 @@ export function usePress(props: PressHookProps): PressResult { pressProps.onDragStart = (e) => { - if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -673,7 +673,7 @@ export function usePress(props: PressHookProps): PressResult { pressProps.onMouseDown = (e) => { // Only handle left clicks - if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -695,7 +695,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); } @@ -703,7 +703,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseEnter = (e) => { - if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -719,7 +719,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseLeave = (e) => { - if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -736,7 +736,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseUp = (e) => { - if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -746,7 +746,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchStart = (e) => { - if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -773,7 +773,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchMove = (e) => { - if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -801,7 +801,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchEnd = (e) => { - if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -836,7 +836,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchCancel = (e) => { - if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -847,7 +847,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onDragStart = (e) => { - if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -920,7 +920,7 @@ function isHTMLAnchorLink(target: Element): target is HTMLAnchorElement { return target.tagName === 'A' && target.hasAttribute('href'); } -function isValidKeyboardEvent(event: KeyboardEvent, currentTarget: Element): boolean { +function isValidKeyboardEvent(event: KeyboardEvent | globalThis.KeyboardEvent, currentTarget: Element): boolean { const {key, code} = event; const element = currentTarget as HTMLElement; const role = element.getAttribute('role'); diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index 46321a1d4a8..36215b4807f 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. @@ -54,15 +54,15 @@ export function useSyntheticBlurEvent(onBlur: // Most browsers fire a native focusout event in this case, except for Firefox. In that case, we use a // 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. + let target = getEventTarget(e); if ( - e.target instanceof HTMLButtonElement || - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - e.target instanceof HTMLSelectElement + target instanceof HTMLButtonElement || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement ) { stateRef.current.isFocused = true; - let target = e.target; 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 a8302c8c745..19106939ac9 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, useFocusable, useHover, useKeyboard, usePress} from '@react-aria/interactions'; import {menuData} from './utils'; @@ -199,20 +199,20 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re }; let interaction = useRef<{pointerType: string, key?: string} | null>(null); - let onPressUp = (e: PressEvent) => { - if (e.pointerType !== 'keyboard') { - interaction.current = {pointerType: e.pointerType}; + let onPressUp = (pressEvent: PressEvent) => { + if (pressEvent.pointerType !== 'keyboard') { + interaction.current = {pointerType: pressEvent.pointerType}; } // If interacting with mouse, allow the user to mouse down on the trigger button, // drag, and release over an item (matching native behavior). - if (e.pointerType === 'mouse') { + if (pressEvent.pointerType === 'mouse') { if (!isPressedRef.current) { - (e.target as HTMLElement).click(); + (pressEvent.target as HTMLElement).click(); } } - pressUpProp?.(e); + pressUpProp?.(pressEvent); }; let onClick = (e: MouseEvent) => { @@ -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..9436597a16e 100644 --- a/packages/@react-aria/menu/src/useMenuTrigger.ts +++ b/packages/@react-aria/menu/src/useMenuTrigger.ts @@ -114,21 +114,21 @@ export function useMenuTrigger(props: AriaMenuTriggerProps, state: MenuTrigge let pressProps: PressProps = { preventFocusOnPress: true, - onPressStart(e) { + onPressStart(pressEvent) { // For consistency with native, open the menu on mouse/key down, but touch up. - if (e.pointerType !== 'touch' && e.pointerType !== 'keyboard' && !isDisabled) { + if (pressEvent.pointerType !== 'touch' && pressEvent.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(pressEvent.target as FocusableElement); // If opened with a screen reader, auto focus the first item. // Otherwise, the menu itself will be focused. - state.open(e.pointerType === 'virtual' ? 'first' : null); + state.open(pressEvent.pointerType === 'virtual' ? 'first' : null); } }, - onPress(e) { - if (e.pointerType === 'touch' && !isDisabled) { + onPress(pressEvent) { + if (pressEvent.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(pressEvent.target 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..3ed930c9b9f 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -251,7 +251,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt inputProps['aria-required'] = undefined; } - let onButtonPressStart = (e) => { + let onButtonPressStart = (pressEvent) => { // If focus is already on the input, keep it there so we don't hide the // software keyboard when tapping the increment/decrement buttons. if (document.activeElement === inputRef.current) { @@ -261,10 +261,10 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt // Otherwise, when using a mouse, move focus to the input. // On touch, or with a screen reader, focus the button so that the software // keyboard does not appear and the screen reader cursor is not moved off the button. - if (e.pointerType === 'mouse') { + if (pressEvent.pointerType === 'mouse') { inputRef.current?.focus(); } else { - e.target.focus(); + pressEvent.target.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..2b58d27f36a 100644 --- a/packages/@react-aria/radio/src/useRadioGroup.ts +++ b/packages/@react-aria/radio/src/useRadioGroup.ts @@ -12,8 +12,9 @@ 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 {KeyboardEventHandler} from 'react'; import {radioGroupData} from './utils'; import {RadioGroupState} from '@react-stately/radio'; import {useField} from '@react-aria/label'; @@ -75,7 +76,7 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState onFocusWithinChange: props.onFocusChange }); - let onKeyDown = (e) => { + let onKeyDown: KeyboardEventHandler = (e) => { let nextDir; switch (e.key) { case 'ArrowRight': @@ -103,7 +104,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 1a307bf9790..7747c787d95 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..689493f4c08 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 {getEventTarget, mergeProps, useDescription, useEffectEvent, useId} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore 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..30325c5c9e3 100644 --- a/packages/@react-aria/textfield/src/useFormattedTextField.ts +++ b/packages/@react-aria/textfield/src/useFormattedTextField.ts @@ -11,10 +11,10 @@ */ import {AriaTextFieldProps} from '@react-types/textfield'; -import {mergeProps, useEffectEvent} from '@react-aria/utils'; +import {getEventTarget, mergeProps, useEffectEvent} from '@react-aria/utils'; +import {InputEventHandler, useEffect, useRef} from 'react'; import {RefObject} from '@react-types/shared'; import {TextFieldAria, useTextField} from './useTextField'; -import {useEffect, useRef} from 'react'; interface FormattedTextFieldState { validate: (val: string) => boolean, @@ -106,12 +106,12 @@ export function useFormattedTextField(props: AriaTextFieldProps, state: Formatte }; }, [inputRef]); - let onBeforeInput = !supportsNativeBeforeInputEvent() + let onBeforeInput: InputEventHandler | null = !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..f8f8ada7540 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'; @@ -189,8 +189,8 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState 'data-react-aria-top-layer': true, // 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"]'); + onFocus: (e: FocusEvent) => { + let target = (getEventTarget(e) as Element).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..511d9abea53 100644 --- a/packages/@react-aria/toggle/src/useToggle.ts +++ b/packages/@react-aria/toggle/src/useToggle.ts @@ -11,8 +11,8 @@ */ import {AriaToggleProps} from '@react-types/checkbox'; -import {filterDOMProps, mergeProps, useFormReset} from '@react-aria/utils'; -import {InputHTMLAttributes, LabelHTMLAttributes} from 'react'; +import {ChangeEventHandler, InputHTMLAttributes, LabelHTMLAttributes} from 'react'; +import {filterDOMProps, getEventTarget, mergeProps, useFormReset} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {ToggleState} from '@react-stately/toggle'; import {useFocusable, usePress} from '@react-aria/interactions'; @@ -57,11 +57,11 @@ export function useToggle(props: AriaToggleProps, state: ToggleState, ref: RefOb onClick } = props; - let onChange = (e) => { + let onChange: ChangeEventHandler = (e) => { // 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..42c31d6a1bb 100644 --- a/packages/@react-aria/toolbar/src/useToolbar.ts +++ b/packages/@react-aria/toolbar/src/useToolbar.ts @@ -12,8 +12,8 @@ import {AriaLabelingProps, Orientation, RefObject} from '@react-types/shared'; import {createFocusManager} from '@react-aria/focus'; -import {filterDOMProps, nodeContains, useLayoutEffect} from '@react-aria/utils'; -import {HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; +import {filterDOMProps, getEventTarget, nodeContains, useLayoutEffect} from '@react-aria/utils'; +import {FocusEventHandler, HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; import {useLocale} from '@react-aria/i18n'; export interface AriaToolbarProps extends AriaLabelingProps { @@ -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 ( @@ -100,17 +100,17 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject(null); - const onBlur = (e) => { + const onBlur: FocusEventHandler = (e) => { if (!nodeContains(e.currentTarget, e.relatedTarget) && !lastFocused.current) { - lastFocused.current = e.target; + lastFocused.current = getEventTarget(e) as HTMLElement; } }; // Restore focus to the last focused child when focus returns into the toolbar. // If the element was removed, do nothing, either the first item in the first group, // or the last item in the last group will be focused, depending on direction. - const onFocus = (e) => { - if (lastFocused.current && !nodeContains(e.currentTarget, e.relatedTarget) && nodeContains(ref.current, e.target)) { + const onFocus: FocusEventHandler = (e) => { + 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..ff6ccc193bd 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -3,13 +3,14 @@ import {isShadowRoot} from '../domHelpers'; import {shadowDOM} from '@react-stately/flags'; +import type {SyntheticEvent} from 'react'; /** * ShadowDOM safe version of Node.contains. */ export function nodeContains( - node: Node | null | undefined, - otherNode: Node | null | undefined + node: Node | Element | null | undefined, + otherNode: Node | Element | null | undefined ): boolean { if (!shadowDOM()) { return otherNode && node ? node.contains(otherNode) : false; @@ -58,14 +59,21 @@ export const getActiveElement = (doc: Document = document): Element | null => { return activeElement; }; +// Type helper to extract the target element type from an event +type EventTargetType = T extends SyntheticEvent ? E : EventTarget; + /** * ShadowDOM safe version of event.target. */ -export function getEventTarget(event: T): Element { - if (shadowDOM() && (event.target as HTMLElement).shadowRoot) { - if (event.composedPath) { - return event.composedPath()[0] as Element; +export function getEventTarget(event: T): EventTargetType { + // For React synthetic events, use the native event + let nativeEvent: Event = 'nativeEvent' in event ? (event as SyntheticEvent).nativeEvent : event as Event; + let target = nativeEvent.target!; + + if (shadowDOM() && (target as HTMLElement).shadowRoot) { + if (nativeEvent.composedPath) { + return nativeEvent.composedPath()[0] as EventTargetType; } } - return event.target as Element; + return target as EventTargetType; } 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..0c26934e9ea 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 {getEventTarget, useId} from '@react-aria/utils'; import {IconContext} from './Icon'; // @ts-ignore 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..5b103b2c24f 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -303,10 +303,10 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne { keyboardDelegate, dropTargetDelegate: treeDropTargetDelegate, - onDropActivate: (e) => { + onDropActivate: (dragEvent) => { // Expand collapsed item when dragging over. For keyboard, allow collapsing. - if (e.target.type === 'item') { - let key = e.target.key; + if (dragEvent.target.type === 'item') { + let key = dragEvent.target.key; let item = state.collection.getItem(key); let isExpanded = expandedKeys !== 'all' && expandedKeys.has(key); if (item && item.hasChildNodes && (!isExpanded || dragAndDropHooks?.isVirtualDragging?.())) {