diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba8fa9452bf..75755330fba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -202,7 +202,7 @@ parcel build packages/@react-{spectrum,aria,stately}/*/ packages/@internationali make: *** [build] Segmentation fault: 11 ``` -It's likely that you are using a different version of Node.js. Please use Node.js 18. When changing the node version, delete `node_modules` and re-run `yarn install` +It's likely that you are using a different version of Node.js. Please use Node.js 22. When changing the node version, delete `node_modules` and re-run `yarn install` > `yarn start` fails. diff --git a/package.json b/package.json index d7a0bf1dfc0..734ef6ae558 100644 --- a/package.json +++ b/package.json @@ -297,5 +297,9 @@ }, "locales": [ "en-US" - ] + ], + "volta": { + "node": "22.20.0", + "yarn": "4.2.2" + } } diff --git a/packages/@react-aria/actiongroup/src/useActionGroup.ts b/packages/@react-aria/actiongroup/src/useActionGroup.ts index 336e32ac79d..14b0f24b3f7 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, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, nodeContains, useLayoutEffect} from '@react-aria/utils'; import {ListState} from '@react-stately/list'; import {useLocale} from '@react-aria/i18n'; -import {useState} from 'react'; +import {useState, KeyboardEvent} 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 (!e.currentTarget.contains(e.target)) { + let onKeyDown = (e: KeyboardEvent) => { + 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 313615d5977..42ea24c500a 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, useObjectRef} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getEventTarget, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus'; import {getInteractionModality} 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; } @@ -221,7 +221,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; } @@ -325,7 +325,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) { @@ -382,12 +382,14 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let curFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null; if (curFocusedNode) { - let target = e.target; - 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); - dispatchVirtualFocus(collectionRef.current!, target); - }); + let target = getEventTarget(e); + if (target instanceof Element) { + 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); + dispatchVirtualFocus(collectionRef.current!, target); + }); + } } }; diff --git a/packages/@react-aria/calendar/src/useCalendarCell.ts b/packages/@react-aria/calendar/src/useCalendarCell.ts index aabeac2f9a5..a63c6f0824c 100644 --- a/packages/@react-aria/calendar/src/useCalendarCell.ts +++ b/packages/@react-aria/calendar/src/useCalendarCell.ts @@ -13,9 +13,10 @@ 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'; +import {getActiveElement} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import {useDateFormatter, useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -291,7 +292,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta // Also only scroll into view if the cell actually got focused. // There are some cases where the cell might be disabled or inside, // an inert container and we don't want to scroll then. - if (getInteractionModality() !== 'pointer' && document.activeElement === ref.current) { + if (getInteractionModality() !== 'pointer' && getActiveElement(document) === ref.current) { scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)}); } } @@ -334,11 +335,12 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta } }, onPointerDown(e) { + const eventTarget = getEventTarget(e); // This is necessary on touch devices to allow dragging // outside the original pressed element. // (JSDOM does not support this) - if ('releasePointerCapture' in e.target) { - e.target.releasePointerCapture(e.pointerId); + if (eventTarget instanceof Element && 'releasePointerCapture' in eventTarget) { + eventTarget.releasePointerCapture(e.pointerId); } }, onContextMenu(e) { diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index 87695d4268d..82abb66d235 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -14,7 +14,7 @@ import {AriaRangeCalendarProps, DateValue} from '@react-types/calendar'; import {CalendarAria, useCalendarBase} from './useCalendarBase'; import {FocusableElement, RefObject} from '@react-types/shared'; import {RangeCalendarState} from '@react-stately/calendar'; -import {useEvent} from '@react-aria/utils'; +import {getActiveElement, useEvent, nodeContains, getEventTarget} from '@react-aria/utils'; import {useRef} from 'react'; /** @@ -49,11 +49,11 @@ export function useRangeCalendar(props: AriaRangeCalendarPr return; } - let target = e.target as Element; + let target = getEventTarget(e) as Element; if ( ref.current && - ref.current.contains(document.activeElement) && - (!ref.current.contains(target) || !target.closest('button, [role="button"]')) + nodeContains(ref.current, getActiveElement(document)) && + (!nodeContains(ref.current, target) || !target.closest('button, [role="button"]')) ) { state.selectFocusedDate(); } @@ -66,7 +66,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr if (!ref.current) { return; } - if ((!e.relatedTarget || !ref.current.contains(e.relatedTarget)) && state.anchorDate) { + if ((!e.relatedTarget || !nodeContains(ref.current, e.relatedTarget)) && state.anchorDate) { state.selectFocusedDate(); } }; diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index 5fe460012f0..a74752974a8 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -16,7 +16,7 @@ import {AriaComboBoxProps} from '@react-types/combobox'; import {ariaHideOutside} from '@react-aria/overlays'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; -import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, useEvent, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; +import {chain, getActiveElement, getEventTarget, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; import {ComboBoxState} from '@react-stately/combobox'; import {dispatchVirtualFocus} from '@react-aria/focus'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react'; @@ -181,7 +181,7 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta let onBlur = (e: FocusEvent) => { let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; - let blurIntoPopover = popoverRef.current?.contains(e.relatedTarget); + let blurIntoPopover = nodeContains(popoverRef.current, e.relatedTarget); // Ignore blur if focused moved to the button(if exists) or into the popover. if (blurFromButton || blurIntoPopover) { return; @@ -262,7 +262,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/useDatePicker.ts b/packages/@react-aria/datepicker/src/useDatePicker.ts index f768b34df88..2677b39c020 100644 --- a/packages/@react-aria/datepicker/src/useDatePicker.ts +++ b/packages/@react-aria/datepicker/src/useDatePicker.ts @@ -17,7 +17,7 @@ import {CalendarProps} from '@react-types/calendar'; import {createFocusManager} from '@react-aria/focus'; import {DatePickerState} from '@react-stately/datepicker'; import {DOMAttributes, GroupDOMAttributes, KeyboardEvent, RefObject, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useDescription, useId} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import {privateValidationStateProp} from '@react-stately/form'; @@ -84,7 +84,7 @@ export function useDatePicker(props: AriaDatePickerProps onBlurWithin: e => { // Ignore when focus moves into the popover. let dialog = document.getElementById(dialogId); - if (!dialog?.contains(e.relatedTarget)) { + if (!nodeContains(dialog, e.relatedTarget)) { isFocused.current = false; props.onBlur?.(e); props.onFocusChange?.(false); diff --git a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts index 862ba7e08a0..8de9cae918e 100644 --- a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts +++ b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts @@ -1,7 +1,7 @@ import {createFocusManager, getFocusableTreeWalker} from '@react-aria/focus'; import {DateFieldState, DatePickerState, DateRangePickerState} from '@react-stately/datepicker'; import {DOMAttributes, FocusableElement, KeyboardEvent, RefObject} from '@react-types/shared'; -import {mergeProps} from '@react-aria/utils'; +import {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 (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { 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) { @@ -68,7 +68,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState return; } // Try to find the segment prior to the element that was clicked on. - let target = window.event?.target as FocusableElement; + let target = window.event ? getEventTarget(window.event) as FocusableElement : null; let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); if (target) { walker.currentNode = target; diff --git a/packages/@react-aria/datepicker/src/useDateRangePicker.ts b/packages/@react-aria/datepicker/src/useDateRangePicker.ts index 6e9c748455b..5614487fced 100644 --- a/packages/@react-aria/datepicker/src/useDateRangePicker.ts +++ b/packages/@react-aria/datepicker/src/useDateRangePicker.ts @@ -18,7 +18,7 @@ import {DateRange, RangeCalendarProps} from '@react-types/calendar'; import {DateRangePickerState} from '@react-stately/datepicker'; import {DEFAULT_VALIDATION_RESULT, mergeValidation, privateValidationStateProp} from '@react-stately/form'; import {DOMAttributes, GroupDOMAttributes, KeyboardEvent, RefObject, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useDescription, useId} from '@react-aria/utils'; import {focusManagerSymbol, roleSymbol} from './useDateField'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -116,7 +116,7 @@ export function useDateRangePicker(props: AriaDateRangePick onBlurWithin: e => { // Ignore when focus moves into the popover. let dialog = document.getElementById(dialogId); - if (!dialog?.contains(e.relatedTarget)) { + if (!nodeContains(dialog, e.relatedTarget)) { isFocused.current = false; props.onBlur?.(e); props.onFocusChange?.(false); diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index 2aad84bc0ea..41cf84e1700 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -12,12 +12,13 @@ import {CalendarDate, toCalendar} from '@internationalized/date'; import {DateFieldState, DateSegment} from '@react-stately/datepicker'; -import {getScrollParent, isIOS, isMac, mergeProps, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils'; +import {getScrollParent, isIOS, isMac, mergeProps, nodeContains, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils'; import {hookData} from './useDateField'; import {NumberParser} from '@internationalized/number'; import React, {CSSProperties, useMemo, useRef} from 'react'; import {RefObject} from '@react-types/shared'; import {useDateFormatter, useFilter, useLocale} from '@react-aria/i18n'; +import {getActiveElement} from '@react-aria/utils'; import {useDisplayNames} from './useDisplayNames'; import {useSpinButton} from '@react-aria/spinbutton'; @@ -281,7 +282,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: // Otherwise, when tapping on a segment in Android Chrome and then entering text, // composition events will be fired that break the DOM structure and crash the page. let selection = window.getSelection(); - if (selection?.anchorNode && ref.current?.contains(selection?.anchorNode)) { + if (selection?.anchorNode && nodeContains(ref.current, selection?.anchorNode)) { selection.collapse(ref.current); } }); @@ -339,7 +340,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: let element = ref.current; return () => { // If the focused segment is removed, focus the previous one, or the next one if there was no previous one. - if (document.activeElement === element) { + if (getActiveElement(document) === element) { let prev = focusManager.focusPrevious(); if (!prev) { focusManager.focusNext(); diff --git a/packages/@react-aria/dialog/src/useDialog.ts b/packages/@react-aria/dialog/src/useDialog.ts index 33c4a144b5a..b2f64d1f001 100644 --- a/packages/@react-aria/dialog/src/useDialog.ts +++ b/packages/@react-aria/dialog/src/useDialog.ts @@ -12,7 +12,7 @@ import {AriaDialogProps} from '@react-types/dialog'; import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {filterDOMProps, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, getActiveElement, nodeContains, useSlotId} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {useEffect, useRef} from 'react'; import {useOverlayFocusContain} from '@react-aria/overlays'; @@ -40,7 +40,7 @@ export function useDialog(props: AriaDialogProps, ref: RefObject { - if (ref.current && !ref.current.contains(document.activeElement)) { + if (ref.current && !nodeContains(ref.current, getActiveElement(document))) { focusSafely(ref.current); // Safari on iOS does not move the VoiceOver cursor to the dialog @@ -48,7 +48,7 @@ export function useDialog(props: AriaDialogProps, ref: RefObject { // Check that the dialog is still focused, or focused was lost to the body. - if (document.activeElement === ref.current || document.activeElement === document.body) { + if (getActiveElement(document) === ref.current || getActiveElement(document) === document.body) { isRefocusing.current = true; if (ref.current) { ref.current.blur(); diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts index 2128797ffb4..5188bd0364b 100644 --- a/packages/@react-aria/dnd/src/DragManager.ts +++ b/packages/@react-aria/dnd/src/DragManager.ts @@ -14,7 +14,7 @@ import {announce} from '@react-aria/live-announcer'; import {ariaHideOutside} from '@react-aria/overlays'; import {DragEndEvent, DragItem, DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropItem, DropOperation, DropTarget as DroppableCollectionTarget, FocusableElement} from '@react-types/shared'; import {getDragModality, getTypes} from './utils'; -import {isVirtualClick, isVirtualPointerEvent} from '@react-aria/utils'; +import {getActiveElement, getEventTarget, isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils'; import type {LocalizedStringFormatter} from '@internationalized/string'; import {RefObject, useEffect, useState} from 'react'; @@ -114,7 +114,7 @@ function endDragging() { export function isValidDropTarget(element: Element): boolean { for (let target of dropTargets.keys()) { - if (target.contains(element)) { + if (nodeContains(target, element)) { return true; } } @@ -243,7 +243,7 @@ class DragSession { this.cancelEvent(e); if (e.key === 'Enter') { - if (e.altKey || this.getCurrentActivateButton()?.contains(e.target as Node)) { + if (e.altKey || nodeContains(this.getCurrentActivateButton(), getEventTarget(e) as Node)) { this.activate(this.currentDropTarget, this.currentDropItem); } else { this.drop(); @@ -257,28 +257,29 @@ class DragSession { onFocus(e: FocusEvent): void { let activateButton = this.getCurrentActivateButton(); - if (e.target === activateButton) { + let eventTarget = getEventTarget(e); + if (eventTarget === 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 (eventTarget !== 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 (!(eventTarget instanceof HTMLElement) || eventTarget === this.dragTarget.element) { return; } let dropTarget = - this.validDropTargets.find(target => target.element === e.target as HTMLElement) || - this.validDropTargets.find(target => target.element.contains(e.target as HTMLElement)); + this.validDropTargets.find(target => target.element === eventTarget as HTMLElement) || + this.validDropTargets.find(target => nodeContains(target.element, eventTarget as HTMLElement)); if (!dropTarget) { - // if (e.target === activateButton) { + // if (eventTarget === activateButton) { // activateButton.focus(); // } if (this.currentDropTarget) { @@ -289,7 +290,7 @@ class DragSession { return; } - let item = dropItems.get(e.target as HTMLElement); + let item = dropItems.get(eventTarget as HTMLElement); if (dropTarget) { this.setCurrentDropTarget(dropTarget, item); } @@ -302,7 +303,7 @@ class DragSession { return; } - if (e.target !== this.dragTarget.element) { + if (getEventTarget(e) !== this.dragTarget.element) { this.cancelEvent(e); } @@ -321,15 +322,16 @@ class DragSession { this.cancelEvent(e); if (isVirtualClick(e) || this.isVirtualClick) { let dropElements = dropItems.values(); - let item = [...dropElements].find(item => item.element === e.target as HTMLElement || item.activateButtonRef?.current?.contains(e.target as HTMLElement)); - let dropTarget = this.validDropTargets.find(target => target.element.contains(e.target as HTMLElement)); + let eventTarget = getEventTarget(e); + let item = [...dropElements].find(item => item.element === eventTarget as HTMLElement || nodeContains(item.activateButtonRef?.current, eventTarget as HTMLElement)); + let dropTarget = this.validDropTargets.find(target => nodeContains(target.element, eventTarget as HTMLElement)); let activateButton = item?.activateButtonRef?.current ?? dropTarget?.activateButtonRef?.current; - if (activateButton?.contains(e.target as HTMLElement) && dropTarget) { + if (nodeContains(activateButton, eventTarget as HTMLElement) && dropTarget) { this.activate(dropTarget, item); return; } - if (e.target === this.dragTarget.element) { + if (eventTarget === this.dragTarget.element) { this.cancel(); return; } @@ -350,7 +352,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; } @@ -401,7 +403,7 @@ class DragSession { // Filter out drop targets that contain valid items. We don't want to stop hiding elements // other than the drop items that exist inside the collection. let visibleDropTargets = this.validDropTargets.filter(target => - !validDropItems.some(item => target.element.contains(item.element)) + !validDropItems.some(item => nodeContains(target.element, item.element)) ); this.restoreAriaHidden = ariaHideOutside([ @@ -418,7 +420,7 @@ class DragSession { // For now, the activate button is reachable by screen readers and ArrowLeft/ArrowRight // is usable specifically by Tree. Will need tabbing for other components. // let activateButton = this.getCurrentActivateButton(); - // if (activateButton && document.activeElement !== activateButton) { + // if (activateButton && getActiveElement(document) !== activateButton) { // activateButton.focus(); // return; // } @@ -450,7 +452,7 @@ class DragSession { previous(): void { // let activateButton = this.getCurrentActivateButton(); - // if (activateButton && document.activeElement === activateButton) { + // if (activateButton && getActiveElement(document) === activateButton) { // let target = this.currentDropItem ?? this.currentDropTarget; // if (target) { // target.element.focus(); @@ -570,7 +572,7 @@ class DragSession { // Re-trigger focus event on active element, since it will not have received it during dragging (see cancelEvent). // This corrects state such as whether focus ring should appear. // useDroppableCollection handles this itself, so this is only for standalone drop zones. - document.activeElement?.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + getActiveElement(document)?.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); } this.setCurrentDropTarget(null); diff --git a/packages/@react-aria/dnd/src/useDrag.ts b/packages/@react-aria/dnd/src/useDrag.ts index 6cfbb9be7b1..f7e28d32801 100644 --- a/packages/@react-aria/dnd/src/useDrag.ts +++ b/packages/@react-aria/dnd/src/useDrag.ts @@ -18,7 +18,7 @@ import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, EFFECT_ALLOWED} from './c 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 {getEventTarget, 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); }); @@ -340,16 +340,17 @@ 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') { + let eventTarget = getEventTarget(e); + if (eventTarget === e.currentTarget && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); - startDragging(e.target as HTMLElement); + startDragging(eventTarget as HTMLElement); } }, onClick(e) { @@ -357,7 +358,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 09985b2d7a5..1a33e2e1ebb 100644 --- a/packages/@react-aria/dnd/src/useDrop.ts +++ b/packages/@react-aria/dnd/src/useDrop.ts @@ -16,7 +16,7 @@ import {DragEvent, useRef, useState} from 'react'; import * as DragManager from './DragManager'; import {DragTypes, globalAllowedDropOperations, globalDndState, readFromDataTransfer, setGlobalDnDState, setGlobalDropEffect} from './utils'; import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, DROP_OPERATION_ALLOWED, DROP_OPERATION_TO_DROP_EFFECT} from './constants'; -import {isIPad, isMac, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {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,9 +232,9 @@ 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 (!e.currentTarget.contains(element)) { + if (!nodeContains(e.currentTarget, element)) { state.dragOverElements.delete(element); } } diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 6dad540400a..f00df699059 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -19,6 +19,7 @@ import { isChrome, isFocusable, isTabbable, + nodeContains, ShadowTreeWalker, useLayoutEffect } from '@react-aria/utils'; @@ -391,7 +392,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo let modality = getInteractionModality(); let shouldSkipFocusRestore = (modality === 'virtual' || modality === null) && isAndroid() && isChrome(); - // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe + // Use getActiveElement(document) instead of e.relatedTarget so we can tell if user clicked into iframe let activeElement = getActiveElement(ownerDocument); if (!shouldSkipFocusRestore && activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(activeElement, scopeRef)) { activeScope = scopeRef; @@ -440,7 +441,7 @@ function isElementInScope(element?: Element | null, scope?: Element[] | null) { if (!scope) { return false; } - return scope.some(node => node.contains(element)); + return scope.some(node => nodeContains(node, element)); } function isElementInChildScope(element: Element, scope: ScopeRef = null) { @@ -619,7 +620,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b return; } - let focusedElement = ownerDocument.activeElement as FocusableElement; + let focusedElement = getActiveElement(ownerDocument) as FocusableElement; if (!isElementInChildScope(focusedElement, scopeRef) || !shouldRestoreFocus(scopeRef)) { return; } @@ -712,7 +713,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b let clonedTree = focusScopeTree.clone(); requestAnimationFrame(() => { // Only restore focus if we've lost focus to the body, the alternative is that focus has been purposefully moved elsewhere - if (ownerDocument.activeElement === ownerDocument.body) { + if (getActiveElement(ownerDocument) === ownerDocument.body) { // look up the tree starting with our scope to find a nodeToRestore still in the DOM let treeNode = clonedTree.getTreeNode(scopeRef); while (treeNode) { @@ -771,7 +772,7 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions { acceptNode(node) { // Skip nodes inside the starting node. - if (opts?.from?.contains(node)) { + if (opts?.from && nodeContains(opts.from, node)) { return NodeFilter.FILTER_REJECT; } @@ -822,7 +823,7 @@ export function createFocusManager(ref: RefObject, defaultOption let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; let node = from || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); - if (root.contains(node)) { + if (nodeContains(root, node)) { walker.currentNode = node!; } let nextNode = walker.nextNode() as FocusableElement; @@ -843,7 +844,7 @@ export function createFocusManager(ref: RefObject, defaultOption let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; let node = from || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); - if (root.contains(node)) { + if (nodeContains(root, node)) { walker.currentNode = node!; } else { let next = last(walker); diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index ef0b104c084..c903616ddcd 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -20,6 +20,7 @@ import {Provider} from '@react-spectrum/provider'; import React, {useEffect, useState} from 'react'; import ReactDOM from 'react-dom'; import {Example as StorybookExample} from '../stories/FocusScope.stories'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {useEvent} from '@react-aria/utils'; import userEvent from '@testing-library/user-event'; @@ -2150,6 +2151,208 @@ describe('FocusScope with Shadow DOM', function () { unmount(); document.body.removeChild(shadowHost); }); + + it('should reproduce the specific issue #8675: Menu items in popover close immediately with UNSAFE_PortalProvider', async function () { + const {shadowRoot, cleanup} = createShadowRoot(); + let actionExecuted = false; + let menuClosed = false; + + // Create portal container within the shadow DOM for the popover + const popoverPortal = document.createElement('div'); + popoverPortal.setAttribute('data-testid', 'popover-portal'); + shadowRoot.appendChild(popoverPortal); + + // This reproduces the exact scenario described in the issue + function WebComponentWithReactApp() { + const [isPopoverOpen, setIsPopoverOpen] = React.useState(true); + + const handleMenuAction = key => { + actionExecuted = true; + // In the original issue, this never executes because the popover closes first + console.log('Menu action executed:', key); + }; + + return ( + shadowRoot}> +
+ + {/* Portal the popover overlay to simulate real-world usage */} + {isPopoverOpen && + ReactDOM.createPortal( + +
+ +
+ + +
+
+
+
, + popoverPortal + )} +
+
+ ); + } + + const {unmount} = render(); + + // Wait for rendering + act(() => { + jest.runAllTimers(); + }); + + // Query elements from shadow DOM + const saveMenuItem = shadowRoot.querySelector('[data-testid="menu-item-save"]'); + const exportMenuItem = shadowRoot.querySelector('[data-testid="menu-item-export"]'); + const menuContainer = shadowRoot.querySelector('[data-testid="menu-container"]'); + const popoverOverlay = shadowRoot.querySelector('[data-testid="popover-overlay"]'); + // const closeButton = shadowRoot.querySelector('[data-testid="close-popover"]'); + + // Verify the menu is initially visible in shadow DOM + expect(popoverOverlay).not.toBeNull(); + expect(menuContainer).not.toBeNull(); + expect(saveMenuItem).not.toBeNull(); + expect(exportMenuItem).not.toBeNull(); + + // Focus the first menu item + act(() => { + saveMenuItem.focus(); + }); + expect(shadowRoot.activeElement).toBe(saveMenuItem); + + // Click the menu item - this should execute the onAction handler, NOT close the menu + await user.click(saveMenuItem); + + // The action should have been executed (this would fail in the buggy version) + expect(actionExecuted).toBe(true); + + // The menu should still be open (this would fail in the buggy version where it closes immediately) + expect(menuClosed).toBe(false); + expect(shadowRoot.querySelector('[data-testid="menu-container"]')).not.toBeNull(); + + // Test focus containment within the menu + act(() => { + saveMenuItem.focus(); + }); + await user.tab(); + expect(shadowRoot.activeElement).toBe(exportMenuItem); + + await user.tab(); + // Focus should wrap back to first item due to containment + expect(shadowRoot.activeElement).toBe(saveMenuItem); + + // Cleanup + unmount(); + cleanup(); + }); + + it('should handle web component scenario with multiple nested portals and UNSAFE_PortalProvider', async function () { + const {shadowRoot, cleanup} = createShadowRoot(); + + // Create nested portal containers within the shadow DOM + const modalPortal = document.createElement('div'); + modalPortal.setAttribute('data-testid', 'modal-portal'); + shadowRoot.appendChild(modalPortal); + + const tooltipPortal = document.createElement('div'); + tooltipPortal.setAttribute('data-testid', 'tooltip-portal'); + shadowRoot.appendChild(tooltipPortal); + + function ComplexWebComponent() { + const [showModal, setShowModal] = React.useState(true); + const [showTooltip] = React.useState(true); + + return ( + shadowRoot}> +
+ + + {/* Modal with its own focus scope */} + {showModal && + ReactDOM.createPortal( + +
+ + + +
+
, + modalPortal + )} + + {/* Tooltip with nested focus scope */} + {showTooltip && + ReactDOM.createPortal( + +
+ +
+
, + tooltipPortal + )} +
+
+ ); + } + + const {unmount} = render(); + + const modalButton1 = shadowRoot.querySelector('[data-testid="modal-button-1"]'); + const modalButton2 = shadowRoot.querySelector('[data-testid="modal-button-2"]'); + const tooltipAction = shadowRoot.querySelector('[data-testid="tooltip-action"]'); + + // Due to autoFocus, the first modal button should be focused + act(() => { + jest.runAllTimers(); + }); + expect(shadowRoot.activeElement).toBe(modalButton1); + + // Tab navigation should work within the modal + await user.tab(); + expect(shadowRoot.activeElement).toBe(modalButton2); + + // Focus should be contained within the modal due to the contain prop + await user.tab(); + // Should cycle to the close button + expect(shadowRoot.activeElement.getAttribute('data-testid')).toBe('close-modal'); + + await user.tab(); + // Should wrap back to first modal button + expect(shadowRoot.activeElement).toBe(modalButton1); + + // The tooltip button should be focusable when we explicitly focus it + act(() => { + tooltipAction.focus(); + }); + act(() => { + jest.runAllTimers(); + }); + // But due to modal containment, focus should be restored back to modal + expect(shadowRoot.activeElement).toBe(modalButton1); + + // Cleanup + unmount(); + cleanup(); + }); }); describe('Unmounting cleanup', () => { diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 5a8c9935efd..43596517724 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -11,7 +11,7 @@ */ import {AriaLabelingProps, DOMAttributes, DOMProps, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, mergeProps, nodeContains, useId} from '@react-aria/utils'; import {GridCollection} from '@react-types/grid'; import {GridKeyboardDelegate} from './GridKeyboardDelegate'; import {gridMap} from './utils'; @@ -133,10 +133,10 @@ export function useGrid(props: GridProps, state: GridState { + let onFocus = useCallback((e: FocusEvent) => { if (manager.isFocused) { // If a focus event bubbled through a portal, reset focus state. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { manager.setFocused(false); } @@ -144,7 +144,7 @@ export function useGrid(props: GridProps, state: GridState>(props: GridCellProps let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { // If focus is already on a focusable child within the cell, early return so we don't shift focus - if (ref.current.contains(document.activeElement) && ref.current !== document.activeElement) { + if (nodeContains(ref.current, getActiveElement(document)) && ref.current !== getActiveElement(document)) { return; } @@ -90,7 +90,7 @@ export function useGridCell>(props: GridCellProps if ( (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !ref.current.contains(document.activeElement) + !nodeContains(ref.current, getActiveElement(document)) ) { focusSafely(ref.current); } @@ -109,12 +109,13 @@ export function useGridCell>(props: GridCellProps }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!e.currentTarget.contains(e.target as Element) || state.isKeyboardNavigationDisabled || !ref.current || !document.activeElement) { + let activeElement = getActiveElement(document); + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || state.isKeyboardNavigationDisabled || !ref.current || !activeElement) { return; } let walker = getFocusableTreeWalker(ref.current); - walker.currentNode = document.activeElement; + walker.currentNode = activeElement; switch (e.key) { case 'ArrowLeft': { @@ -213,7 +214,7 @@ export function useGridCell>(props: GridCellProps // Prevent this event from reaching cell children, e.g. menu buttons. We want arrow keys to navigate // to the cell above/below instead. We need to re-dispatch the event from a higher parent so it still // bubbles and gets handled by useSelectableCollection. - if (!e.altKey && ref.current.contains(e.target as Element)) { + if (!e.altKey && nodeContains(ref.current, getEventTarget(e) as Element)) { e.stopPropagation(); e.preventDefault(); ref.current.parentElement?.dispatchEvent( @@ -228,7 +229,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. @@ -244,7 +245,7 @@ export function useGridCell>(props: GridCellProps // If the cell itself is focused, wait a frame so that focus finishes propagatating // up to the tree, and move focus to a focusable child if possible. requestAnimationFrame(() => { - if (focusMode === 'child' && document.activeElement === ref.current) { + if (focusMode === 'child' && getActiveElement(document) === ref.current) { focus(); } }); diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 2ecfc4f8125..f01eae49e36 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getScrollParent, mergeProps, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; +import {chain, getActiveElement, 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'; @@ -79,7 +79,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt if ( ref.current !== null && ((keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !ref.current?.contains(document.activeElement)) + !nodeContains(ref.current, getActiveElement(document))) ) { focusSafely(ref.current); } @@ -131,14 +131,15 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!e.currentTarget.contains(e.target as Element) || !ref.current || !document.activeElement) { + let activeElement = getActiveElement(document) + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || !activeElement) { return; } let walker = getFocusableTreeWalker(ref.current); - walker.currentNode = document.activeElement; + walker.currentNode = activeElement; - if ('expandedKeys' in state && document.activeElement === ref.current) { + if ('expandedKeys' in state && getActiveElement(document) === ref.current) { if ((e.key === EXPANSION_KEYS['expand'][direction]) && state.selectionManager.focusedKey === node.key && hasChildRows && !state.expandedKeys.has(node.key)) { state.toggleKey(node.key); e.stopPropagation(); @@ -216,7 +217,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt // Prevent this event from reaching row children, e.g. menu buttons. We want arrow keys to navigate // to the row above/below instead. We need to re-dispatch the event from a higher parent so it still // bubbles and gets handled by useSelectableCollection. - if (!e.altKey && ref.current.contains(e.target as Element)) { + if (!e.altKey && nodeContains(ref.current, getEventTarget(e) as Element)) { e.stopPropagation(); e.preventDefault(); ref.current.parentElement?.dispatchEvent( @@ -229,7 +230,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 +245,8 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }; let onKeyDown = (e) => { - if (!e.currentTarget.contains(e.target as Element) || !ref.current || !document.activeElement) { + let activeElement = getActiveElement(document) + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || !activeElement) { return; } @@ -254,7 +256,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt // If there is another focusable element within this item, stop propagation so the tab key // is handled by the browser and not by useSelectableCollection (which would take us out of the list). let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); - walker.currentNode = document.activeElement; + walker.currentNode = activeElement; let next = e.shiftKey ? walker.previousNode() : walker.nextNode(); if (next) { diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index d2c910ecedd..6ebd1551030 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); } @@ -60,12 +60,14 @@ export function useFocus(pro const onSyntheticFocus = useSyntheticBlurEvent(onBlur); const onFocus: FocusProps['onFocus'] = useCallback((e: FocusEvent) => { - // Double check that document.activeElement actually matches e.target in case a previously chained + // Double check that getActiveElement(document) actually matches getEventTarget(e) in case a previously chained // focus handler already moved focus somewhere else. - const ownerDocument = getOwnerDocument(e.target); + const eventTarget = getEventTarget(e); + const target = eventTarget instanceof Element ? eventTarget : null; + const ownerDocument = getOwnerDocument(target); const activeElement = ownerDocument ? getActiveElement(ownerDocument) : getActiveElement(); - if (e.target === e.currentTarget && activeElement === getEventTarget(e.nativeEvent)) { + if (target === 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 1ffd18463cb..0b56ead1c12 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} from '@react-aria/utils'; +import {getActiveElement, getEventTarget, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick} from '@react-aria/utils'; import {ignoreFocusEvent} from './utils'; import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; @@ -90,10 +90,12 @@ function handleClickEvent(e: MouseEvent) { } function handleFocusEvent(e: FocusEvent) { + const eventTarget = getEventTarget(e); // 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 ( + eventTarget === window || eventTarget === document || ignoreFocusEvent || !e.isTrusted) { return; } @@ -290,18 +292,20 @@ 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; + const eventTarget = e ? getEventTarget(e) as Element: null; + let document = getOwnerDocument(e ? getEventTarget(e) as Element: null); + const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).HTMLInputElement : HTMLInputElement; + const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).HTMLTextAreaElement : HTMLTextAreaElement; + const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).HTMLElement : HTMLElement; + const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).KeyboardEvent : KeyboardEvent; + const activeElement = getActiveElement(document); // 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 isTextInput = isTextInput || - (document.activeElement instanceof IHTMLInputElement && !nonTextInputTypes.has(document.activeElement.type)) || - document.activeElement instanceof IHTMLTextAreaElement || - (document.activeElement instanceof IHTMLElement && document.activeElement.isContentEditable); + (activeElement instanceof IHTMLInputElement && !nonTextInputTypes.has(activeElement.type)) || + activeElement instanceof IHTMLTextAreaElement || + (activeElement instanceof IHTMLElement && activeElement.isContentEditable); return !(isTextInput && modality === 'keyboard' && e instanceof IKeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[e.key]); } diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 9e1c839b612..eeb63ee24f8 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -54,14 +54,14 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onBlur = useCallback((e: FocusEvent) => { // Ignore events bubbling through portals. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } // We don't want to trigger onBlurWithin and then immediately onFocusWithin again // when moving focus inside the element. Only trigger if the currentTarget doesn't // include the relatedTarget (where focus is moving). - if (state.current.isFocusWithin && !(e.currentTarget as Element).contains(e.relatedTarget as Element)) { + if (state.current.isFocusWithin && !nodeContains(e.currentTarget, e.relatedTarget)) { state.current.isFocusWithin = false; removeAllGlobalListeners(); @@ -77,14 +77,15 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onSyntheticFocus = useSyntheticBlurEvent(onBlur); let onFocus = useCallback((e: FocusEvent) => { + const eventTarget = getEventTarget(e); // Ignore events bubbling through portals. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, eventTarget)) { return; } - // Double check that document.activeElement actually matches e.target in case a previously chained + // Double check that getActiveElement(document) actually matches eventTarget in case a previously chained // focus handler already moved focus somewhere else. - const ownerDocument = getOwnerDocument(e.target); + const ownerDocument = getOwnerDocument(eventTarget instanceof Element ? eventTarget : null); const activeElement = getActiveElement(ownerDocument); if (!state.current.isFocusWithin && activeElement === getEventTarget(e.nativeEvent)) { if (onFocusWithin) { @@ -103,8 +104,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, eventTarget)) { + let nativeEvent = new ownerDocument.defaultView!.FocusEvent('blur', {relatedTarget: eventTarget}); 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 6c5c69ad0c1..b133f62e36f 100644 --- a/packages/@react-aria/interactions/src/useHover.ts +++ b/packages/@react-aria/interactions/src/useHover.ts @@ -15,9 +15,9 @@ // NOTICE file in the root directory of this source tree. // 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 {useEffect, useMemo, useRef, useState} from 'react'; +import {DOMAttributes, FocusableElement, HoverEvent, HoverEvents} from '@react-types/shared'; +import {getEventTarget, getOwnerDocument, nodeContains, useGlobalListeners} from '@react-aria/utils'; +import {SyntheticEvent, useEffect, useMemo, useRef, useState} from 'react'; export interface HoverProps extends HoverEvents { /** Whether the hover events should be disabled. */ @@ -99,16 +99,17 @@ export function useHover(props: HoverProps): HoverResult { isHovered: false, ignoreEmulatedMouseEvents: false, pointerType: '', - target: null + target: null as (EventTarget & FocusableElement) | null }).current; useEffect(setupGlobalTouchEvents, []); let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners(); let {hoverProps, triggerHoverEnd} = useMemo(() => { - let triggerHoverStart = (event, pointerType) => { + let triggerHoverStart = (event: SyntheticEvent, pointerType: string) => { state.pointerType = pointerType; - if (isDisabled || pointerType === 'touch' || state.isHovered || !event.currentTarget.contains(event.target)) { + let eventTarget = getEventTarget(event); + if (isDisabled || pointerType === 'touch' || state.isHovered || !nodeContains(event.currentTarget, eventTarget)) { return; } @@ -120,8 +121,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(eventTarget instanceof Element ? eventTarget : null), 'pointerover', e => { + if (state.isHovered && state.target && !nodeContains(state.target, getEventTarget(e) as Element)) { triggerHoverEnd(e, e.pointerType); } }, {capture: true}); @@ -129,8 +130,8 @@ export function useHover(props: HoverProps): HoverResult { if (onHoverStart) { onHoverStart({ type: 'hoverstart', - target, - pointerType + target: target as HTMLElement, + pointerType: pointerType as HoverEvent['pointerType'] }); } @@ -141,7 +142,7 @@ export function useHover(props: HoverProps): HoverResult { setHovered(true); }; - let triggerHoverEnd = (event, pointerType) => { + let triggerHoverEnd = (_event, pointerType: string) => { let target = state.target; state.pointerType = ''; state.target = null; @@ -156,8 +157,8 @@ export function useHover(props: HoverProps): HoverResult { if (onHoverEnd) { onHoverEnd({ type: 'hoverend', - target, - pointerType + target: target as HTMLElement, + pointerType: pointerType as HoverEvent['pointerType'] }); } @@ -180,7 +181,7 @@ export function useHover(props: HoverProps): HoverResult { }; hoverProps.onPointerLeave = (e) => { - if (!isDisabled && e.currentTarget.contains(e.target as Element)) { + if (!isDisabled && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { triggerHoverEnd(e, e.pointerType); } }; @@ -198,7 +199,7 @@ export function useHover(props: HoverProps): HoverResult { }; hoverProps.onMouseLeave = (e) => { - if (!isDisabled && e.currentTarget.contains(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 94c1e65a46c..c7bda70170c 100644 --- a/packages/@react-aria/interactions/src/useInteractOutside.ts +++ b/packages/@react-aria/interactions/src/useInteractOutside.ts @@ -15,7 +15,7 @@ // NOTICE file in the root directory of this source tree. // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions -import {getOwnerDocument, useEffectEvent} from '@react-aria/utils'; +import {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 eventTarget = getEventTarget(event); + if (eventTarget instanceof Element) { // if the event target is no longer in the document, ignore - const ownerDocument = event.target.ownerDocument; - if (!ownerDocument || !ownerDocument.documentElement.contains(event.target)) { + const ownerDocument = eventTarget.ownerDocument; + if (!ownerDocument || !nodeContains(ownerDocument.documentElement, eventTarget)) { 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 (eventTarget.closest('[data-react-aria-top-layer]')) { return false; } } @@ -134,7 +135,7 @@ function isValidEvent(event, ref) { return false; } - // When the event source is inside a Shadow DOM, event.target is just the shadow root. + // When the event source is inside a Shadow DOM, getEventTarget(event) is just the shadow root. // Using event.composedPath instead means we can get the actual element inside the shadow root. // This only works if the shadow root is open, there is no way to detect if it is closed. // If the event composed path contains the ref, interaction is inside. diff --git a/packages/@react-aria/interactions/src/useLongPress.ts b/packages/@react-aria/interactions/src/useLongPress.ts index 1f910e487f5..9288b26f320 100644 --- a/packages/@react-aria/interactions/src/useLongPress.ts +++ b/packages/@react-aria/interactions/src/useLongPress.ts @@ -11,7 +11,7 @@ */ import {DOMAttributes, FocusableElement, LongPressEvent} from '@react-types/shared'; -import {focusWithoutScrolling, getOwnerDocument, mergeProps, useDescription, useGlobalListeners} from '@react-aria/utils'; +import {focusWithoutScrolling, getActiveElement, getOwnerDocument, mergeProps, useDescription, useGlobalListeners} from '@react-aria/utils'; import {usePress} from './usePress'; import {useRef} from 'react'; @@ -83,7 +83,7 @@ export function useLongPress(props: LongPressProps): LongPressResult { e.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) { + if (getActiveElement(getOwnerDocument(e.target)) !== e.target) { focusWithoutScrolling(e.target as FocusableElement); } diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 42576b36095..82962534162 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -326,8 +326,9 @@ 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)) { + let eventTarget = getEventTarget(e.nativeEvent); + if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && nodeContains(e.currentTarget, eventTarget) && eventTarget instanceof Element) { + if (shouldPreventDefaultKeyboard(eventTarget, e.key)) { e.preventDefault(); } @@ -345,7 +346,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 = e.currentTarget; - let pressUp = (e) => { + let pressUp = (e: KeyboardEvent) => { if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e)) && state.target) { triggerPressUp(createEvent(state.target, e), 'keyboard'); } @@ -411,30 +412,32 @@ export function usePress(props: PressHookProps): PressResult { let onKeyUp = (e: KeyboardEvent) => { if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) { - if (shouldPreventDefaultKeyboard(getEventTarget(e), e.key)) { - e.preventDefault(); - } - let target = getEventTarget(e); - let wasPressed = nodeContains(state.target, getEventTarget(e)); - triggerPressEnd(createEvent(state.target, e), 'keyboard', wasPressed); - if (wasPressed) { - triggerSyntheticClick(e, state.target); - } - removeAllGlobalListeners(); + if(target instanceof Element) { + if (shouldPreventDefaultKeyboard(target, e.key)) { + e.preventDefault(); + } - // If a link was triggered with a key other than Enter, open the URL ourselves. - // This means the link has a role override, and the default browser behavior - // only applies when using the Enter key. - if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && nodeContains(state.target, target) && !e[LINK_CLICKED]) { - // Store a hidden property on the event so we only trigger link click once, - // even if there are multiple usePress instances attached to the element. - e[LINK_CLICKED] = true; - openLink(state.target, e, false); - } + let wasPressed = nodeContains(state.target, target); + triggerPressEnd(createEvent(state.target, e), 'keyboard', wasPressed); + if (wasPressed) { + triggerSyntheticClick(e, state.target); + } + removeAllGlobalListeners(); + + // If a link was triggered with a key other than Enter, open the URL ourselves. + // This means the link has a role override, and the default browser behavior + // only applies when using the Enter key. + if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && nodeContains(state.target, target) && !e[LINK_CLICKED]) { + // Store a hidden property on the event so we only trigger link click once, + // even if there are multiple usePress instances attached to the element. + e[LINK_CLICKED] = true; + openLink(state.target, e, false); + } - state.isPressed = false; - state.metaKeyEvents?.delete(e.key); + state.isPressed = false; + state.metaKeyEvents?.delete(e.key); + } } else if (e.key === 'Meta' && state.metaKeyEvents?.size) { // If we recorded keydown events that occurred while the Meta key was pressed, // and those haven't received keyup events already, fire keyup events ourselves. @@ -481,7 +484,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); - if ('releasePointerCapture' in target) { + if (target instanceof Element && 'releasePointerCapture' in target) { target.releasePointerCapture(e.pointerId); } @@ -501,7 +504,7 @@ export function usePress(props: PressHookProps): PressResult { if (e.button === 0) { if (preventFocusOnPress) { - let dispose = preventFocus(e.target as FocusableElement); + let dispose = preventFocus(getEventTarget(e) as FocusableElement); if (dispose) { state.disposables.push(dispose); } @@ -614,7 +617,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); } @@ -677,7 +680,7 @@ export function usePress(props: PressHookProps): PressResult { return; } - if (state.target && state.target.contains(e.target as Element) && state.pointerType != null) { + if (state.target && nodeContains(state.target, 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 { @@ -872,7 +875,7 @@ export function usePress(props: PressHookProps): PressResult { } function isHTMLAnchorLink(target: Element): target is HTMLAnchorElement { - return target.tagName === 'A' && target.hasAttribute('href'); + return !!target && target.tagName === 'A' && target.hasAttribute('href'); } function isValidKeyboardEvent(event: KeyboardEvent, currentTarget: Element): boolean { diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index f31da638bc9..d7270c51ae6 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, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getActiveElement, getEventTarget, getOwnerWindow, isFocusable, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; // Turn a native event into a React synthetic event. @@ -54,19 +54,19 @@ export function useSyntheticBlurEvent(onBlur: // This function is called during a React onFocus event. return useCallback((e: ReactFocusEvent) => { + let target = getEventTarget(e); // React does not fire onBlur when an element is disabled. https://github.com/facebook/react/issues/9142 // 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. 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; @@ -88,7 +88,8 @@ export function useSyntheticBlurEvent(onBlur: stateRef.current.observer = new MutationObserver(() => { if (stateRef.current.isFocused && target.disabled) { stateRef.current.observer?.disconnect(); - let relatedTargetEl = target === document.activeElement ? null : document.activeElement; + let activeElement = getActiveElement(document); + let relatedTargetEl = target === activeElement ? null : activeElement; target.dispatchEvent(new FocusEvent('blur', {relatedTarget: relatedTargetEl})); target.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget: relatedTargetEl})); } @@ -113,7 +114,7 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un } let window = getOwnerWindow(target); - let activeElement = window.document.activeElement as FocusableElement | null; + let activeElement = getActiveElement(window.document) as FocusableElement | null; if (!activeElement || activeElement === target) { return; } @@ -121,13 +122,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. @@ -141,13 +142,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/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index cdc2aa07a40..844705874f7 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -10,10 +10,13 @@ * governing permissions and limitations under the License. */ -import {fireEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, fireEvent, installPointerEvent, pointerMap, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {enableShadowDOM} from '@react-stately/flags'; import React, {useEffect, useRef} from 'react'; import ReactDOM, {createPortal} from 'react-dom'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {useInteractOutside} from '../'; +import userEvent from '@testing-library/user-event'; function Example(props) { let ref = useRef(); @@ -593,3 +596,89 @@ describe('useInteractOutside shadow DOM extended tests', function () { cleanup(); }); }); + + +describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { + let user; + + beforeAll(() => { + enableShadowDOM(); + user = userEvent.setup({delay: null, pointerMap}); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runAllTimers(); + }); + }); + + it('should handle interact outside events with UNSAFE_PortalProvider in shadow DOM', async () => { + const {shadowRoot, cleanup} = createShadowRoot(); + let interactOutsideTriggered = false; + + // Create portal container within the shadow DOM for the popover + const popoverPortal = document.createElement('div'); + popoverPortal.setAttribute('data-testid', 'popover-portal'); + shadowRoot.appendChild(popoverPortal); + + function ShadowInteractOutsideExample() { + const ref = useRef(); + useInteractOutside({ + ref, + onInteractOutside: () => { + interactOutsideTriggered = true; + } + }); + + return ( + shadowRoot}> +
+ {ReactDOM.createPortal( + <> +
+ + +
+ + , + popoverPortal + )} +
+
+ ); + } + + const {unmount} = render(); + + const target = shadowRoot.querySelector('[data-testid="target"]'); + const innerButton = shadowRoot.querySelector( + '[data-testid="inner-button"]' + ); + const outsideButton = shadowRoot.querySelector( + '[data-testid="outside-button"]' + ); + + // Click inside the target - should NOT trigger interact outside + await user.click(innerButton); + expect(interactOutsideTriggered).toBe(false); + + // Click the target itself - should NOT trigger interact outside + await user.click(target); + expect(interactOutsideTriggered).toBe(false); + + // Click outside the target within shadow DOM - should trigger interact outside + await user.click(outsideButton); + expect(interactOutsideTriggered).toBe(true); + + // Cleanup + unmount(); + cleanup(); + }); +}); diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts index aea8768c8f0..5a5a297adf4 100644 --- a/packages/@react-aria/landmark/src/useLandmark.ts +++ b/packages/@react-aria/landmark/src/useLandmark.ts @@ -12,7 +12,7 @@ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; import {useCallback, useEffect, useState} from 'react'; -import {useLayoutEffect} from '@react-aria/utils'; +import {getActiveElement, getEventTarget, useLayoutEffect, nodeContains} from '@react-aria/utils'; import {useSyncExternalStore} from 'use-sync-external-store/shim/index.js'; export type AriaLandmarkRole = 'main' | 'region' | 'search' | 'navigation' | 'form' | 'banner' | 'contentinfo' | 'complementary'; @@ -55,7 +55,7 @@ interface Landmark { export interface LandmarkControllerOptions { /** * The element from which to start navigating. - * @default document.activeElement + * @default getActiveElement(document) */ from?: FocusableElement } @@ -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(); @@ -325,7 +325,7 @@ class LandmarkManager implements LandmarkManagerApi { private focusMain() { let main = this.getLandmarkByRole('main'); - if (main && main.ref.current && document.contains(main.ref.current)) { + if (main && main.ref.current && nodeContains(document, main.ref.current)) { this.focusLandmark(main.ref.current, 'forward'); return true; } @@ -345,14 +345,14 @@ class LandmarkManager implements LandmarkManagerApi { // If something was previously focused in the next landmark, then return focus to it if (nextLandmark.lastFocused) { let lastFocused = nextLandmark.lastFocused; - if (document.body.contains(lastFocused)) { + if (nodeContains(document.body, lastFocused)) { lastFocused.focus(); return true; } } // Otherwise, focus the landmark itself - if (nextLandmark.ref.current && document.contains(nextLandmark.ref.current)) { + if (nextLandmark.ref.current && nodeContains(document, nextLandmark.ref.current)) { this.focusLandmark(nextLandmark.ref.current, backward ? 'backward' : 'forward'); return true; } @@ -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 @@ -400,15 +400,15 @@ class LandmarkManager implements LandmarkManagerApi { instance.setupIfNeeded(); return { navigate(direction, opts) { - let element = opts?.from || (document!.activeElement as FocusableElement); + let element = opts?.from || (getActiveElement(document) as FocusableElement); return instance!.navigate(element, direction === 'backward'); }, focusNext(opts) { - let element = opts?.from || (document!.activeElement as FocusableElement); + let element = opts?.from || (getActiveElement(document) as FocusableElement); return instance!.navigate(element, false); }, focusPrevious(opts) { - let element = opts?.from || (document!.activeElement as FocusableElement); + let element = opts?.from || (getActiveElement(document) as FocusableElement); return instance!.navigate(element, true); }, focusMain() { diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 30440abd5d7..b2107c7e635 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -11,7 +11,7 @@ */ import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents, RefObject} from '@react-types/shared'; -import {filterDOMProps, handleLinkClick, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, handleLinkClick, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; import {getItemCount} from '@react-stately/collections'; import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from '@react-aria/interactions'; import {menuData} from './utils'; @@ -279,14 +279,15 @@ 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'}; + let eventTarget = getEventTarget(e); // Trigger click unless this is a link. Links trigger click natively. - if ((e.target as HTMLElement).tagName !== 'A') { - (e.target as HTMLElement).click(); + if (eventTarget instanceof HTMLElement && eventTarget.tagName !== 'A') { + eventTarget.click(); } break; default: diff --git a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts index 1a43971bbe4..9461715dff6 100644 --- a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts +++ b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts @@ -1,7 +1,7 @@ import {RefObject} from '@react-types/shared'; import {useEffect, useRef, useState} from 'react'; -import {useEffectEvent, useResizeObserver} from '@react-aria/utils'; +import {nodeContains, useEffectEvent, useResizeObserver} from '@react-aria/utils'; import {useInteractionModality} from '@react-aria/interactions'; interface SafelyMouseToSubmenuOptions { @@ -148,7 +148,7 @@ export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions): v // Fire a pointerover event to trigger the menu to close. // Wait until pointer-events:none is no longer applied let target = document.elementFromPoint(mouseX, mouseY); - if (target && menu.contains(target)) { + if (target && nodeContains(menu, target)) { target.dispatchEvent(new PointerEvent('pointerover', {bubbles: true, cancelable: true})); } }, 100); diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 590ce43a213..fe241f8b3bd 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, useEffectEvent, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getActiveElement, getEventTarget, nodeContains, useEffectEvent, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; import type {SubmenuTriggerState} from '@react-stately/menu'; import {useCallback, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -100,13 +100,13 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm let submenuKeyDown = (e: KeyboardEvent) => { // If focus is not within the menu, assume virtual focus is being used. // This means some other input element is also within the popover, so we shouldn't close the menu. - if (!e.currentTarget.contains(document.activeElement)) { + if (!nodeContains(e.currentTarget, getActiveElement(document))) { return; } switch (e.key) { case 'ArrowLeft': - if (direction === 'ltr' && e.currentTarget.contains(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' && e.currentTarget.contains(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 (submenuRef.current?.contains(e.target as Element)) { + if (nodeContains(submenuRef.current, getEventTarget(e) as Element)) { e.stopPropagation(); onSubmenuClose(); if (!shouldUseVirtualFocus && ref.current) { @@ -159,7 +159,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm onSubmenuOpen('first'); } - if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) { + if (type === 'menu' && !!submenuRef?.current && getActiveElement(document) === ref?.current) { focusWithoutScrolling(submenuRef.current); } } else if (state.isOpen) { @@ -178,7 +178,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm onSubmenuOpen('first'); } - if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) { + if (type === 'menu' && !!submenuRef?.current && getActiveElement(document) === ref?.current) { focusWithoutScrolling(submenuRef.current); } } else if (state.isOpen) { @@ -226,7 +226,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm useEvent(parentMenuRef, 'focusin', (e) => { // If we detect focus moved to a different item in the same menu that the currently open submenu trigger is in // then close the submenu. This is for a case where the user hovers a root menu item when multiple submenus are open - if (state.isOpen && (parentMenuRef.current?.contains(e.target as HTMLElement) && e.target !== ref.current)) { + if (state.isOpen && nodeContains(parentMenuRef.current, 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 08da12b97a5..ff43b8f48c6 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -12,7 +12,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaNumberFieldProps} from '@react-types/numberfield'; -import {chain, filterDOMProps, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; +import {chain, filterDOMProps, getActiveElement, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; import {DOMAttributes, GroupDOMAttributes, TextInputDOMProps, ValidationResult} from '@react-types/shared'; import { InputHTMLAttributes, @@ -254,7 +254,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt let onButtonPressStart = (e) => { // 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) { + if (getActiveElement(document) === inputRef.current) { return; } @@ -264,7 +264,10 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt if (e.pointerType === 'mouse') { inputRef.current?.focus(); } else { - e.target.focus(); + const eventTarget = getEventTarget(e) as EventTarget; + if (eventTarget && 'focus' in eventTarget && typeof eventTarget.focus === 'function') { + eventTarget.focus(); + } } }; diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 753c2a926a3..2bd6368f3fa 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {getOwnerWindow} from '@react-aria/utils'; +import {createShadowTreeWalker, getOwnerDocument, getOwnerWindow, nodeContains} from '@react-aria/utils'; const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype; interface AriaHideOutsideOptions { @@ -85,7 +85,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt // Skip this node but continue to children if one of the targets is inside the node. for (let target of visibleNodes) { - if (node.contains(target)) { + if (nodeContains(node, target)) { return NodeFilter.FILTER_SKIP; } } @@ -93,8 +93,11 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt return NodeFilter.FILTER_ACCEPT; }; - let walker = document.createTreeWalker( - root, + let rootElement = root?.nodeType === Node.ELEMENT_NODE ? (root as Element) : null; + let doc = getOwnerDocument(rootElement); + let walker = createShadowTreeWalker( + doc, + root || doc, NodeFilter.SHOW_ELEMENT, {acceptNode} ); @@ -147,7 +150,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt // If the parent element of the added nodes is not within one of the targets, // and not already inside a hidden node, hide all of the new children. - if (![...visibleNodes, ...hiddenNodes].some(node => node.contains(change.target))) { + if (![...visibleNodes, ...hiddenNodes].some(node => nodeContains(node, change.target))) { for (let node of change.addedNodes) { if ( (node instanceof HTMLElement || node instanceof SVGElement) && diff --git a/packages/@react-aria/overlays/src/useCloseOnScroll.ts b/packages/@react-aria/overlays/src/useCloseOnScroll.ts index 23899dccbf8..539df203212 100644 --- a/packages/@react-aria/overlays/src/useCloseOnScroll.ts +++ b/packages/@react-aria/overlays/src/useCloseOnScroll.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {getEventTarget, nodeContains} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect} from 'react'; @@ -37,16 +38,16 @@ 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) && !target.contains(triggerRef.current))) { + if (!triggerRef.current || ((target instanceof Node) && !nodeContains(target, triggerRef.current))) { return; } // 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 (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } diff --git a/packages/@react-aria/overlays/src/useOverlay.ts b/packages/@react-aria/overlays/src/useOverlay.ts index 8fdcfa39410..06473b03769 100644 --- a/packages/@react-aria/overlays/src/useOverlay.ts +++ b/packages/@react-aria/overlays/src/useOverlay.ts @@ -14,6 +14,7 @@ import {DOMAttributes, RefObject} from '@react-types/shared'; import {isElementInChildOfActiveScope} from '@react-aria/focus'; import {useEffect} from 'react'; import {useFocusWithin, useInteractOutside} from '@react-aria/interactions'; +import {getEventTarget} from '@react-aria/utils'; export interface AriaOverlayProps { /** Whether the overlay is currently open. */ @@ -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/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index 59c61a08075..efb180d4656 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -15,7 +15,7 @@ import {DOMAttributes, RefObject} from '@react-types/shared'; import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; -import {useLayoutEffect, useResizeObserver} from '@react-aria/utils'; +import {getActiveElement, nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; export interface AriaPositionProps extends PositionProps { @@ -154,8 +154,8 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { // so it can be restored after repositioning. This way if the overlay height // changes, the focused element appears to stay in the same position. let anchor: ScrollAnchor | null = null; - if (scrollRef.current && scrollRef.current.contains(document.activeElement)) { - let anchorRect = document.activeElement?.getBoundingClientRect(); + if (scrollRef.current && nodeContains(scrollRef.current, getActiveElement(document))) { + let anchorRect = getActiveElement(document)?.getBoundingClientRect(); let scrollRect = scrollRef.current.getBoundingClientRect(); // Anchor from the top if the offset is in the top half of the scrollable element, // otherwise anchor from the bottom. @@ -207,9 +207,10 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { Object.keys(position.position).forEach(key => overlay.style[key] = (position.position!)[key] + 'px'); overlay.style.maxHeight = position.maxHeight != null ? position.maxHeight + 'px' : ''; + let activeElement = getActiveElement(document); // Restore scroll position relative to anchor element. - if (anchor && document.activeElement && scrollRef.current) { - let anchorRect = document.activeElement.getBoundingClientRect(); + if (anchor && activeElement && scrollRef.current) { + let anchorRect = activeElement.getBoundingClientRect(); let scrollRect = scrollRef.current.getBoundingClientRect(); let newOffset = anchorRect[anchor.type] - scrollRect[anchor.type]; scrollRef.current.scrollTop += newOffset - anchor.offset; diff --git a/packages/@react-aria/overlays/src/usePreventScroll.ts b/packages/@react-aria/overlays/src/usePreventScroll.ts index 6c4b7ee5772..c4074385ebc 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, getActiveElement, 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; @@ -111,7 +111,7 @@ function preventScrollMobileSafari() { 'selectionStart' in target && 'selectionEnd' in target && (target.selectionStart as number) < (target.selectionEnd as number) && - target.ownerDocument.activeElement === target + getActiveElement(target.ownerDocument) === target ) { allowTouchMove = true; } @@ -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. @@ -174,8 +174,9 @@ function preventScrollMobileSafari() { // Override programmatic focus to scroll into view without scrolling the whole page. let focus = HTMLElement.prototype.focus; HTMLElement.prototype.focus = function (opts) { + let activeElement = getActiveElement(document) // Track whether the keyboard was already visible before. - let wasKeyboardVisible = document.activeElement != null && willOpenKeyboard(document.activeElement); + let wasKeyboardVisible = activeElement != null && willOpenKeyboard(activeElement); // Focus the element without scrolling the page. focus.call(this, {...opts, preventScroll: true}); diff --git a/packages/@react-aria/overlays/test/usePopover.test.tsx b/packages/@react-aria/overlays/test/usePopover.test.tsx index 1b65f9edf23..aa57850a1d1 100644 --- a/packages/@react-aria/overlays/test/usePopover.test.tsx +++ b/packages/@react-aria/overlays/test/usePopover.test.tsx @@ -10,10 +10,13 @@ * governing permissions and limitations under the License. */ -import {fireEvent, render} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {enableShadowDOM} from '@react-stately/flags'; import {type OverlayTriggerProps, useOverlayTriggerState} from '@react-stately/overlays'; import React, {useRef} from 'react'; -import {useOverlayTrigger, usePopover} from '../'; +import ReactDOM from 'react-dom'; +import {UNSAFE_PortalProvider, useOverlayTrigger, usePopover} from '../'; +import userEvent from '@testing-library/user-event'; function Example(props: OverlayTriggerProps) { const triggerRef = useRef(null); @@ -39,3 +42,134 @@ describe('usePopover', () => { expect(onOpenChange).not.toHaveBeenCalled(); }); }); + +describe('usePopover with Shadow DOM and UNSAFE_PortalProvider', () => { + let user; + + beforeAll(() => { + enableShadowDOM(); + user = userEvent.setup({delay: null, pointerMap}); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runAllTimers(); + }); + }); + + it('should handle popover interactions with UNSAFE_PortalProvider in shadow DOM', async () => { + const {shadowRoot} = createShadowRoot(); + let triggerClicked = false; + let popoverInteracted = false; + + const popoverPortal = document.createElement('div'); + popoverPortal.setAttribute('data-testid', 'popover-portal'); + shadowRoot.appendChild(popoverPortal); + + function ShadowPopoverExample() { + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const state = useOverlayTriggerState({ + defaultOpen: false, + onOpenChange: isOpen => { + // Track state changes + } + }); + + useOverlayTrigger({type: 'listbox'}, state, triggerRef); + const {popoverProps} = usePopover( + { + triggerRef, + popoverRef, + placement: 'bottom start' + }, + state + ); + + return ( + shadowRoot as unknown as HTMLElement}> +
+ + {ReactDOM.createPortal( + <> + + {state.isOpen && ( +
+ + +
+ )} + , + popoverPortal + )} + +
+
+ ); + } + + const {unmount} = render(); + + const trigger = shadowRoot.querySelector('[data-testid="popover-trigger"]'); + + // Click trigger to open popover + await user.click(trigger); + expect(triggerClicked).toBe(true); + + // Verify popover opened in shadow DOM + const popoverContent = shadowRoot.querySelector('[data-testid="popover-content"]'); + expect(popoverContent).toBeInTheDocument(); + + // Interact with popover content + const popoverAction = shadowRoot.querySelector('[data-testid="popover-action"]'); + await user.click(popoverAction); + expect(popoverInteracted).toBe(true); + + // Popover should still be open after interaction + expect(shadowRoot.querySelector('[data-testid="popover-content"]')).toBeInTheDocument(); + + // Close popover + const closeButton = shadowRoot.querySelector('[data-testid="close-popover"]'); + await user.click(closeButton); + + // Wait for any cleanup + act(() => { + jest.runAllTimers(); + }); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); +}); diff --git a/packages/@react-aria/radio/src/useRadioGroup.ts b/packages/@react-aria/radio/src/useRadioGroup.ts index df09b13fe1d..dd0ac7f5d84 100644 --- a/packages/@react-aria/radio/src/useRadioGroup.ts +++ b/packages/@react-aria/radio/src/useRadioGroup.ts @@ -12,7 +12,7 @@ import {AriaRadioGroupProps} from '@react-types/radio'; import {DOMAttributes, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, getOwnerWindow, mergeProps, useId} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, getOwnerWindow, mergeProps, useId} from '@react-aria/utils'; import {getFocusableTreeWalker} from '@react-aria/focus'; import {radioGroupData} from './utils'; import {RadioGroupState} from '@react-stately/radio'; @@ -102,8 +102,9 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState return; } e.preventDefault(); + const eventTarget = getEventTarget(e); let walker = getFocusableTreeWalker(e.currentTarget, { - from: e.target, + from: eventTarget instanceof Element ? eventTarget : undefined, 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..cd4a5310808 100644 --- a/packages/@react-aria/select/src/HiddenSelect.tsx +++ b/packages/@react-aria/select/src/HiddenSelect.tsx @@ -15,7 +15,7 @@ import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'r import {selectData} from './useSelect'; import {SelectionMode} from '@react-types/select'; import {SelectState} from '@react-stately/select'; -import {useFormReset} from '@react-aria/utils'; +import {getEventTarget, useFormReset} from '@react-aria/utils'; import {useFormValidation} from '@react-aria/form'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; @@ -92,9 +92,10 @@ export function useHiddenSelect(props: Ar let setValue = state.setValue; let onChange = useCallback((e: React.ChangeEvent) => { - if (e.target.multiple) { + const eventTarget = getEventTarget(e); + if (eventTarget.multiple) { setValue(Array.from( - e.target.selectedOptions, + eventTarget.selectedOptions, (option) => option.value ) as any); } else { diff --git a/packages/@react-aria/select/src/useSelect.ts b/packages/@react-aria/select/src/useSelect.ts index daebc1d3910..11dc057176e 100644 --- a/packages/@react-aria/select/src/useSelect.ts +++ b/packages/@react-aria/select/src/useSelect.ts @@ -13,7 +13,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaListBoxOptions} from '@react-aria/listbox'; import {AriaSelectProps, SelectionMode} from '@react-types/select'; -import {chain, filterDOMProps, mergeProps, useId} from '@react-aria/utils'; +import {chain, filterDOMProps, mergeProps, nodeContains, useId} from '@react-aria/utils'; import {DOMAttributes, KeyboardDelegate, RefObject, ValidationResult} from '@react-types/shared'; import {FocusEvent, useMemo} from 'react'; import {HiddenSelectProps} from './HiddenSelect'; @@ -223,7 +223,7 @@ export function useSelect(props: AriaSele disallowEmptySelection: true, linkBehavior: 'selection', onBlur: (e) => { - if (e.currentTarget.contains(e.relatedTarget as Node)) { + if (nodeContains(e.currentTarget, e.relatedTarget as Node)) { return; } diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 825888ffea6..a42ef0362d9 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, mergeProps, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, getEventTarget, isCtrlKeyPressed, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEffectEvent, 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'; @@ -134,7 +134,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // Keyboard events bubble through portals. Don't handle keyboard events // for elements outside the collection (e.g. menus). - if (!ref.current?.contains(e.target as Element)) { + if (!nodeContains(ref.current, getEventTarget(e) as Element)) { return; } @@ -292,7 +292,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } break; case 'Tab': { - if (!allowsTabNavigation) { + if (!allowsTabNavigation && ref.current) { // There may be elements that are "tabbable" inside a collection (e.g. in a grid cell). // However, collections should be treated as a single tab stop, with arrow key navigation internally. // We don't control the rendering of these, so we can't override the tabIndex to prevent tabbing. @@ -312,7 +312,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } } while (last); - if (next && !next.contains(document.activeElement)) { + if (next && !nodeContains(next, getActiveElement(document))) { focusWithoutScrolling(next); } } @@ -333,9 +333,10 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions }); let onFocus = (e: FocusEvent) => { + let eventTarget = getEventTarget(e); if (manager.isFocused) { // If a focus event bubbled through a portal, reset focus state. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, eventTarget)) { manager.setFocused(false); } @@ -343,7 +344,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } // Focus events can bubble through portals. Ignore these events. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, eventTarget)) { return; } @@ -377,7 +378,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let element = getItemElement(ref, manager.focusedKey); if (element instanceof HTMLElement) { // This prevents a flash of focus on the first/last element in the collection, or the collection itself. - if (!element.contains(document.activeElement) && !shouldUseVirtualFocus) { + if (!nodeContains(element, getActiveElement(document)) && !shouldUseVirtualFocus) { focusWithoutScrolling(element); } @@ -391,7 +392,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let onBlur = (e) => { // Don't set blurred and then focused again if moving focus within the collection. - if (!e.currentTarget.contains(e.relatedTarget as HTMLElement)) { + if (!nodeContains(e.currentTarget, e.relatedTarget as HTMLElement)) { manager.setFocused(false); } }; @@ -565,7 +566,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions onBlur, onMouseDown(e) { // Ignore events that bubbled through portals. - if (scrollRef.current === e.target) { + if (scrollRef.current === getEventTarget(e)) { // Prevent focus going to the collection when clicking on the scrollbar. e.preventDefault(); } diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index cd6107a769b..268c871f997 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, getActiveElement, 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'; @@ -169,7 +169,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte if (!shouldUseVirtualFocus) { if (focus) { focus(); - } else if (document.activeElement !== ref.current && ref.current) { + } else if (getActiveElement(document) !== ref.current && ref.current) { focusSafely(ref.current); } } else { @@ -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 6a3e7dd7031..c8145a869ad 100644 --- a/packages/@react-aria/selection/src/useTypeSelect.ts +++ b/packages/@react-aria/selection/src/useTypeSelect.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {nodeContains, getEventTarget} from '@react-aria/utils'; import {DOMAttributes, Key, KeyboardDelegate} from '@react-types/shared'; import {KeyboardEvent, useRef} from 'react'; import {MultipleSelectionManager} from '@react-stately/selection'; @@ -53,7 +54,7 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { let onKeyDown = (e: KeyboardEvent) => { let character = getStringForKey(e.key); - if (!character || e.ctrlKey || e.metaKey || !e.currentTarget.contains(e.target as HTMLElement) || (state.search.length === 0 && character === ' ')) { + if (!character || e.ctrlKey || e.metaKey || !nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement) || (state.search.length === 0 && character === ' ')) { return; } diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 0a8c956ab13..2e6458335d3 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -18,7 +18,7 @@ import {getColumnHeaderId} from './utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {mergeProps, useDescription, useEffectEvent, useId} from '@react-aria/utils'; +import {getActiveElement, 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'; @@ -196,7 +196,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let prevResizingColumn = useRef(null); useEffect(() => { if (prevResizingColumn.current !== resizingColumn && resizingColumn != null && resizingColumn === item.key) { - wasFocusedOnResizeStart.current = document.activeElement === ref.current; + wasFocusedOnResizeStart.current = getActiveElement(document) === ref.current; startResize(item); // Delay focusing input until Android Chrome's delayed click after touchend happens: https://bugs.chromium.org/p/chromium/issues/detail?id=1150073 let timeout = setTimeout(() => focusInput(), 0); diff --git a/packages/@react-aria/test-utils/src/combobox.ts b/packages/@react-aria/test-utils/src/combobox.ts index d95ac6f5711..313f9dc820a 100644 --- a/packages/@react-aria/test-utils/src/combobox.ts +++ b/packages/@react-aria/test-utils/src/combobox.ts @@ -12,6 +12,7 @@ import {act, waitFor, within} from '@testing-library/react'; import {ComboBoxTesterOpts, UserOpts} from './types'; +import { nodeContains } from '../../utils'; interface ComboBoxOpenOpts { /** @@ -176,7 +177,7 @@ export class ComboBoxTester { if (option.getAttribute('href') == null) { await waitFor(() => { - if (document.contains(listbox)) { + if (nodeContains(document, listbox)) { throw new Error('Expected listbox element to not be in the document after selecting an option'); } else { return true; @@ -198,7 +199,7 @@ export class ComboBoxTester { await this.user.keyboard('[Escape]'); await waitFor(() => { - if (document.contains(listbox)) { + if (nodeContains(document, listbox)) { throw new Error('Expected listbox element to not be in the document after selecting an option'); } else { return true; diff --git a/packages/@react-aria/test-utils/src/gridlist.ts b/packages/@react-aria/test-utils/src/gridlist.ts index d5d1c21e082..510e3f6292d 100644 --- a/packages/@react-aria/test-utils/src/gridlist.ts +++ b/packages/@react-aria/test-utils/src/gridlist.ts @@ -13,6 +13,7 @@ import {act, within} from '@testing-library/react'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {GridListTesterOpts, GridRowActionOpts, ToggleGridRowOpts, UserOpts} from './types'; +import {nodeContains} from '../../utils'; interface GridListToggleRowOpts extends ToggleGridRowOpts {} interface GridListRowActionOpts extends GridRowActionOpts {} @@ -66,13 +67,13 @@ export class GridListTester { throw new Error('Option provided is not in the gridlist'); } - if (document.activeElement !== this._gridlist && !this._gridlist.contains(document.activeElement)) { + if (document.activeElement !== this._gridlist && !nodeContains(this._gridlist, document.activeElement)) { act(() => this._gridlist.focus()); } if (document.activeElement === this._gridlist) { await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`); - } else if (this._gridlist.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { + } else if (nodeContains(this._gridlist, document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); } while (document.activeElement!.getAttribute('role') !== 'row'); diff --git a/packages/@react-aria/toast/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts index 04d6a8dceed..9ca985970fa 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, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getEventTarget, mergeProps, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {getInteractionModality, useFocusWithin, useHover} from '@react-aria/interactions'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -189,8 +189,9 @@ 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 eventTarget = getEventTarget(e); + let target = eventTarget instanceof Element ? eventTarget.closest('[role="alertdialog"]') : null; focusedToast.current = toasts.current.findIndex(t => t === target); }, onBlur: () => { diff --git a/packages/@react-aria/toolbar/src/useToolbar.ts b/packages/@react-aria/toolbar/src/useToolbar.ts index b94bb988c57..a3454b929ed 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, useLayoutEffect} from '@react-aria/utils'; -import {HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; +import {filterDOMProps, getActiveElement, getEventTarget, nodeContains, useLayoutEffect} from '@react-aria/utils'; +import {FocusEvent, 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 (!e.currentTarget.contains(e.target as HTMLElement)) { + if (!nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement)) { return; } if ( @@ -81,7 +81,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject(null); - const onBlur = (e) => { - if (!e.currentTarget.contains(e.relatedTarget) && !lastFocused.current) { - lastFocused.current = e.target; + const onBlur = (e: FocusEvent) => { + if (!nodeContains(e.currentTarget, e.relatedTarget) && !lastFocused.current) { + const eventTarget = getEventTarget(e); + if (eventTarget instanceof HTMLElement) { + lastFocused.current = eventTarget; + } } }; // 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 && !e.currentTarget.contains(e.relatedTarget) && ref.current?.contains(e.target)) { + const onFocus = (e: FocusEvent) => { + 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..003e9a89ca1 100644 --- a/packages/@react-aria/utils/src/runAfterTransition.ts +++ b/packages/@react-aria/utils/src/runAfterTransition.ts @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +import { getEventTarget } from "./shadowdom/DOMFunctions"; + // We store a global list of elements that are currently transitioning, // mapped to a set of CSS properties that are transitioning for that element. // This is necessary rather than a simple count of transitions because of browser @@ -31,19 +33,20 @@ function setupGlobalEvents() { } let onTransitionStart = (e: Event) => { - if (!isTransitionEvent(e) || !e.target) { + const eventTarget = getEventTarget(e); + if (!isTransitionEvent(e) || !eventTarget) { return; } // Add the transitioning property to the list for this element. - let transitions = transitionsByElement.get(e.target); + let transitions = transitionsByElement.get(eventTarget); if (!transitions) { transitions = new Set(); - transitionsByElement.set(e.target, transitions); + transitionsByElement.set(eventTarget, 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, { + eventTarget.addEventListener('transitioncancel', onTransitionEnd, { once: true }); } @@ -52,11 +55,12 @@ function setupGlobalEvents() { }; let onTransitionEnd = (e: Event) => { - if (!isTransitionEvent(e) || !e.target) { + const eventTarget = getEventTarget(e); + if (!isTransitionEvent(e) || !eventTarget) { return; } // Remove property from list of transitioning properties. - let properties = transitionsByElement.get(e.target); + let properties = transitionsByElement.get(eventTarget); if (!properties) { return; } @@ -65,8 +69,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); + eventTarget.removeEventListener('transitioncancel', onTransitionEnd); + transitionsByElement.delete(eventTarget); } // If no transitioning elements, call all of the queued callbacks. diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 6cf69e8a17b..39d58526fc0 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -12,6 +12,7 @@ import {getScrollParents} from './getScrollParents'; import {isChrome} from './platform'; +import {nodeContains} from './shadowdom/DOMFunctions'; interface ScrollIntoViewportOpts { /** The optional containing element of the target to be centered in the viewport. */ @@ -113,7 +114,7 @@ function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'| if (child.offsetParent === ancestor) { // Stop once we have found the ancestor we are interested in. break; - } else if (child.offsetParent.contains(ancestor)) { + } else if (nodeContains(child.offsetParent, ancestor)) { // If the ancestor is not `position:relative`, then we stop at // _its_ offset parent, and we subtract off _its_ offset, so that // we end up with the proper offset from child to ancestor. @@ -131,7 +132,7 @@ function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'| * the body (e.g. targetElement is in a popover), this will only scroll the scroll parents of the targetElement up to but not including the body itself. */ export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollIntoViewportOpts): void { - if (targetElement && document.contains(targetElement)) { + if (targetElement && nodeContains(document, targetElement)) { let root = document.scrollingElement || document.documentElement; let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; // If scrolling is not currently prevented then we aren't in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index 1f822a0ef17..79f216a0402 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -1,5 +1,6 @@ // Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 +import {SyntheticEvent} from 'react'; import {isShadowRoot} from '../domHelpers'; import {shadowDOM} from '@react-stately/flags'; @@ -7,14 +8,14 @@ import {shadowDOM} from '@react-stately/flags'; * ShadowDOM safe version of Node.contains. */ export function nodeContains( - node: Node | null | undefined, - otherNode: Node | null | undefined + node: Node | EventTarget | null | undefined, + otherNode: Node | EventTarget | null | undefined ): boolean { if (!shadowDOM()) { - return otherNode && node ? node.contains(otherNode) : false; + return node instanceof Node && otherNode instanceof Node ? node.contains(otherNode) : false; } - if (!node || !otherNode) { + if (!(node instanceof Node) || !(otherNode instanceof Node)) { return false; } @@ -57,14 +58,28 @@ export const getActiveElement = (doc: Document = document): Element | null => { return activeElement; }; +type EventTargetType = { + target: T; +}; + /** * 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: SE): SE extends EventTargetType ? Target : never; +export function getEventTarget(event: Event): Event['target']; +export function getEventTarget>(event: Event | SE): Event['target'] { + if (shadowDOM() && (event.target instanceof Element) && event.target.shadowRoot) { + if ('composedPath' in event) { + return event.composedPath()[0] || null; + } else if ('composedPath' in event.nativeEvent) { + /** If Typescript types are to be strictly trusted, there is a risk + * that the return type of this branch doesn't match the return type of the first overload. + * In practice, SyntheticEvents only seem to have `target: EventTarget & T` when the event + * doesn't bubble. In that case, .composedPath()[0] and .target should always + * be the same. + */ + return event.nativeEvent.composedPath()[0] || null } } - return event.target as Element; + return event.target; } diff --git a/packages/@react-aria/utils/src/useDrag1D.ts b/packages/@react-aria/utils/src/useDrag1D.ts index e907128c9b3..1c0c3ddd82c 100644 --- a/packages/@react-aria/utils/src/useDrag1D.ts +++ b/packages/@react-aria/utils/src/useDrag1D.ts @@ -13,6 +13,7 @@ /* eslint-disable rulesdir/pure-render */ import {getOffset} from './getOffset'; +import {getEventTarget, nodeContains} from './shadowdom/DOMFunctions'; import {Orientation} from '@react-types/shared'; import React, {HTMLAttributes, MutableRefObject, useRef} from 'react'; @@ -80,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) { @@ -99,7 +100,7 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes { const target = e.currentTarget; // If we're already handling dragging on a descendant with useDrag1D, then // we don't want to handle the drag motion on this target as well. - if (draggingElements.some(elt => target.contains(elt))) { + if (draggingElements.some(elt => nodeContains(target, elt))) { return; } draggingElements.push(target); diff --git a/packages/@react-aria/utils/src/useViewportSize.ts b/packages/@react-aria/utils/src/useViewportSize.ts index 30fc9d26385..2209ce1018e 100644 --- a/packages/@react-aria/utils/src/useViewportSize.ts +++ b/packages/@react-aria/utils/src/useViewportSize.ts @@ -13,6 +13,7 @@ import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; import {willOpenKeyboard} from './keyboard'; +import {getActiveElement, getEventTarget} from './shadowdom/DOMFunctions'; interface ViewportSize { width: number, @@ -50,10 +51,11 @@ 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)) { + let activeElement = getActiveElement(document); + if (!activeElement || !willOpenKeyboard(activeElement)) { setSize(size => { let newSize = {width: window.innerWidth, height: window.innerHeight}; if (newSize.width === size.width && newSize.height === size.height) { diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 7c4988a5b3e..f88bad18ed4 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -25,7 +25,7 @@ import React, { useState } from 'react'; import {Rect, Size} from '@react-stately/virtualizer'; -import {useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {getEventTarget, 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/autocomplete/src/MobileSearchAutocomplete.tsx b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx index 242a1e0c91b..01cfe05f441 100644 --- a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx +++ b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx @@ -25,7 +25,7 @@ import {FocusScope, useFocusRing} from '@react-aria/focus'; import intlMessages from '../intl/*.json'; import {ListBoxBase, useListBoxLayout} from '@react-spectrum/listbox'; import Magnifier from '@spectrum-icons/ui/Magnifier'; -import {mergeProps, useFormReset, useId} from '@react-aria/utils'; +import {getActiveElement, mergeProps, useFormReset, useId} from '@react-aria/utils'; import {ProgressCircle} from '@react-spectrum/progress'; import React, { HTMLAttributes, @@ -482,7 +482,7 @@ function SearchAutocompleteTray(props: SearchAutocompleteTrayProps) { }; let onScroll = useCallback(() => { - if (!inputRef.current || document.activeElement !== inputRef.current || !isTouchDown.current) { + if (!inputRef.current || getActiveElement(document) !== inputRef.current || !isTouchDown.current) { return; } diff --git a/packages/@react-spectrum/card/src/CardBase.tsx b/packages/@react-spectrum/card/src/CardBase.tsx index bada5ea8d4e..bc641c5c9a3 100644 --- a/packages/@react-spectrum/card/src/CardBase.tsx +++ b/packages/@react-spectrum/card/src/CardBase.tsx @@ -15,7 +15,7 @@ import {AriaCardProps, SpectrumCardProps} from '@react-types/card'; import {Checkbox} from '@react-spectrum/checkbox'; import {classNames, SlotProvider, useDOMRef, useHasChild, useStyleProps} from '@react-spectrum/utils'; import {DOMRef, Node} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useLayoutEffect, useResizeObserver, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useLayoutEffect, useResizeObserver, useSlotId} from '@react-aria/utils'; import {FocusRing, getFocusableTreeWalker} from '@react-aria/focus'; import React, {HTMLAttributes, useCallback, useMemo, useRef, useState} from 'react'; import styles from '@adobe/spectrum-css-temp/components/card/vars.css'; @@ -104,7 +104,7 @@ export const CardBase = React.forwardRef(function CardBase(pro let walker = getFocusableTreeWalker(gridRef.current); let nextNode = walker.nextNode(); while (nextNode != null) { - if (checkboxRef.current && !checkboxRef.current.UNSAFE_getDOMNode().contains(nextNode)) { + if (checkboxRef.current && !nodeContains(checkboxRef.current.UNSAFE_getDOMNode(), nextNode)) { console.warn('Card does not support focusable elements, please contact the team regarding your use case.'); break; } diff --git a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx index 5954ab301a3..54aa4d7bc52 100644 --- a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx +++ b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx @@ -28,7 +28,7 @@ import {focusSafely, setInteractionModality, useHover} from '@react-aria/interac import intlMessages from '../intl/*.json'; import labelStyles from '@adobe/spectrum-css-temp/components/fieldlabel/vars.css'; import {ListBoxBase, useListBoxLayout} from '@react-spectrum/listbox'; -import {mergeProps, useFormReset, useId, useObjectRef} from '@react-aria/utils'; +import {getActiveElement, mergeProps, useFormReset, useId, useObjectRef} from '@react-aria/utils'; import {ProgressCircle} from '@react-spectrum/progress'; import React, {ForwardedRef, HTMLAttributes, InputHTMLAttributes, ReactElement, ReactNode, useCallback, useEffect, useRef, useState} from 'react'; import searchStyles from '@adobe/spectrum-css-temp/components/search/vars.css'; @@ -436,7 +436,7 @@ function ComboBoxTray(props: ComboBoxTrayProps) { }; let onScroll = useCallback(() => { - if (!inputRef.current || document.activeElement !== inputRef.current || !isTouchDown.current) { + if (!inputRef.current || getActiveElement(document) !== inputRef.current || !isTouchDown.current) { return; } diff --git a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx index b51d3580b4d..7575a720bc4 100644 --- a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx +++ b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx @@ -22,6 +22,7 @@ import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; import {SubmenuTriggerContext, useMenuStateContext} from './context'; import {TrayHeaderWrapper} from './Menu'; import {useSubmenuTrigger} from '@react-aria/menu'; +import {getActiveElement, nodeContains} from '@react-aria/utils'; import {useSubmenuTriggerState} from '@react-stately/menu'; interface MenuDialogTriggerProps { @@ -85,7 +86,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem let [, content] = props.children as [ReactElement, ReactElement]; let onBlurWithin = (e) => { - if (e.relatedTarget && popoverRef.current && (!popoverRef.current.UNSAFE_getDOMNode()?.contains(e.relatedTarget) && !(e.relatedTarget === triggerRef.current && getInteractionModality() === 'pointer'))) { + if (e.relatedTarget && popoverRef.current && (!nodeContains(popoverRef.current.UNSAFE_getDOMNode(), e.relatedTarget) && !(e.relatedTarget === triggerRef.current && getInteractionModality() === 'pointer'))) { if (submenuTriggerState.isOpen) { submenuTriggerState.close(); } @@ -98,7 +99,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem setTraySubmenuAnimation('spectrum-TraySubmenu-exit'); setTimeout(() => { submenuTriggerState.close(); - if (parentMenuRef.current && !parentMenuRef.current.contains(document.activeElement)) { + if (parentMenuRef.current && !nodeContains(parentMenuRef.current, getActiveElement(document))) { parentMenuRef.current.focus(); } }, 220); // Matches transition duration diff --git a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx index 902870bc922..9ec5bbe5348 100644 --- a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx @@ -13,7 +13,7 @@ import {classNames, useIsMobileDevice} from '@react-spectrum/utils'; import {Key} from '@react-types/shared'; import {MenuContext, SubmenuTriggerContext, useMenuStateContext} from './context'; -import {mergeProps} from '@react-aria/utils'; +import {getActiveElement, mergeProps, nodeContains} from '@react-aria/utils'; import {Popover} from '@react-spectrum/overlays'; import React, {type JSX, ReactElement, useRef} from 'react'; import ReactDOM from 'react-dom'; @@ -49,7 +49,7 @@ function SubmenuTrigger(props: SubmenuTriggerProps) { let isMobile = useIsMobileDevice(); let onBackButtonPress = () => { submenuTriggerState.close(); - if (parentMenuRef.current && !parentMenuRef.current.contains(document.activeElement)) { + if (parentMenuRef.current && !nodeContains(parentMenuRef.current, getActiveElement(document))) { parentMenuRef.current.focus(); } }; diff --git a/packages/@react-spectrum/s2/src/Field.tsx b/packages/@react-spectrum/s2/src/Field.tsx index 22c73463a86..d8623939dbe 100644 --- a/packages/@react-spectrum/s2/src/Field.tsx +++ b/packages/@react-spectrum/s2/src/Field.tsx @@ -25,7 +25,7 @@ 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 {getEventTarget, useId} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; interface FieldLabelProps extends Omit, StyleProps { @@ -195,13 +195,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')) { + if (e.pointerType === 'mouse' && !(getEventTarget(e) as Element).closest('button,input,textarea')) { e.preventDefault(); e.currentTarget.querySelector('input')?.focus(); } }} onPointerUp={e => { - if (e.pointerType !== 'mouse' && !(e.target as Element).closest('button,input,textarea')) { + if (e.pointerType !== 'mouse' && !(getEventTarget(e) as Element).closest('button,input,textarea')) { e.preventDefault(); e.currentTarget.querySelector('input')?.focus(); } diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index c6441c762fd..fca38acad88 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -71,7 +71,7 @@ import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg'; import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from '@react-spectrum/utils'; -import {useLayoutEffect, useObjectRef} from '@react-aria/utils'; +import {getActiveElement, nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -1197,7 +1197,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, onOpenChange={setIsOpen} ref={popoverRef} shouldCloseOnInteractOutside={() => { - if (!popoverRef.current?.contains(document.activeElement)) { + if (!nodeContains(popoverRef.current, getActiveElement(document))) { return false; } formRef.current?.requestSubmit(); diff --git a/packages/@react-spectrum/s2/src/Toast.tsx b/packages/@react-spectrum/s2/src/Toast.tsx index 0391cf2bc23..37631d98550 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, useEvent} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, useEvent} from '@react-aria/utils'; import {flushSync} from 'react-dom'; import {focusRing, style} from '../style' with {type: 'macro'}; import {FocusScope, useModalOverlay} from 'react-aria'; @@ -427,7 +427,7 @@ function SpectrumToastList({placement, align}) { 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/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 5934d217e19..439905c25b4 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -33,7 +33,7 @@ import {GridNode} from '@react-types/grid'; import {InsertionIndicator} from './InsertionIndicator'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {isAndroid, mergeProps, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; +import {getActiveElement, isAndroid, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; import {LayoutInfo, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {layoutInfoToStyle, ScrollView, setScrollLeft, VirtualizerItem} from '@react-aria/virtualizer'; @@ -606,9 +606,10 @@ function TableVirtualizer(props: TableVirtualizerProps) { // only that it changes in a resize, and when that happens, we want to sync the body to the // header scroll position useEffect(() => { - if (getInteractionModality() === 'keyboard' && headerRef.current?.contains(document.activeElement) && bodyRef.current) { - scrollIntoView(headerRef.current, document.activeElement as HTMLElement); - scrollIntoViewport(document.activeElement, {containingElement: domRef.current}); + let activeElement = getActiveElement(document) as HTMLElement; + if (getInteractionModality() === 'keyboard' && headerRef.current && nodeContains(headerRef.current, activeElement) && bodyRef.current) { + scrollIntoView(headerRef.current, activeElement); + scrollIntoViewport(activeElement, {containingElement: domRef.current}); bodyRef.current.scrollLeft = headerRef.current.scrollLeft; } }, [state.contentSize, headerRef, bodyRef, domRef]); diff --git a/packages/dev/docs/src/client.js b/packages/dev/docs/src/client.js index f92f63fa291..953da2dc208 100644 --- a/packages/dev/docs/src/client.js +++ b/packages/dev/docs/src/client.js @@ -13,6 +13,7 @@ import {ActionButton, Flex, Link} from '@adobe/react-spectrum'; import DocSearch from './DocSearch'; import docsStyle from './docs.css'; +import {nodeContains} from '@react-aria/utils'; import LinkOut from '@spectrum-icons/workflow/LinkOut'; import {listen} from 'quicklink'; import React, {useEffect, useRef, useState} from 'react'; @@ -82,7 +83,7 @@ function Hamburger() { nav.classList.toggle(docsStyle.visible); - if (nav.classList.contains(docsStyle.visible)) { + if (nodeContains(nav.classList, docsStyle.visible)) { setIsPressed(true); main.setAttribute('aria-hidden', 'true'); themeSwitcher.setAttribute('aria-hidden', 'true'); @@ -108,7 +109,7 @@ function Hamburger() { let removeVisible = (isNotResponsive = false) => { setIsPressed(false); - if (nav.contains(document.activeElement) && !isNotResponsive) { + if (nodeContains(nav, document.activeElement) && !isNotResponsive) { hamburgerButton.focus(); } @@ -131,7 +132,7 @@ function Hamburger() { /* trap keyboard focus within expanded nav */ let onKeydownTab = (event) => { - if (event.keyCode === 9 && nav.classList.contains(docsStyle.visible)) { + if (event.keyCode === 9 && nodeContains(nav.classList, docsStyle.visible)) { let tabbables = nav.querySelectorAll('button, a[href]'); let first = tabbables[0]; let last = tabbables[tabbables.length - 1]; diff --git a/packages/react-aria-components/src/DropZone.tsx b/packages/react-aria-components/src/DropZone.tsx index 1138dd248c0..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, 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,8 +116,8 @@ export const DropZone = forwardRef(function DropZone(props: DropZoneProps, ref: slot={props.slot || undefined} ref={dropzoneRef} onClick={(e) => { - let target = e.target as HTMLElement | null; - while (target && dropzoneRef.current?.contains(target)) { + let target = getEventTarget(e) as HTMLElement | null; + while (target && nodeContains(dropzoneRef.current, target)) { if (isFocusable(target)) { break; } else if (target === dropzoneRef.current) { diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index 95d32ba8fbd..173d70349af 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -20,7 +20,7 @@ import { useContextProps, useRenderProps } from './utils'; -import {filterDOMProps, mergeProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, getActiveElement, mergeProps, nodeContains, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {OverlayArrowContext} from './OverlayArrow'; import {OverlayTriggerProps, OverlayTriggerState, useOverlayTriggerState} from 'react-stately'; @@ -198,7 +198,7 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, clearContexts // Focus the popover itself on mount, unless a child element is already focused. // Skip this for submenus since hovering a submenutrigger should keep focus on the trigger useEffect(() => { - if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !ref.current.contains(document.activeElement)) { + if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !nodeContains(ref.current, getActiveElement(document))) { focusSafely(ref.current); } }, [isDialog, ref, props.trigger]);