Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ export default [{
"rsp-rules/no-react-key": [ERROR],
"rsp-rules/sort-imports": [ERROR],
"rsp-rules/no-non-shadow-contains": [ERROR],
"rsp-rules/faster-node-contains": [ERROR],
"rulesdir/imports": [ERROR],
"rulesdir/useLayoutEffectRule": [ERROR],
"rulesdir/pure-render": [ERROR],
Expand Down Expand Up @@ -430,6 +431,7 @@ export default [{
"rsp-rules/act-events-test": ERROR,
"rsp-rules/no-getByRole-toThrow": ERROR,
"rsp-rules/no-non-shadow-contains": OFF,
"rsp-rules/faster-node-contains": OFF,
"rulesdir/imports": OFF,
"monorepo/no-internal-import": OFF,
"jsdoc/require-jsdoc": OFF
Expand Down Expand Up @@ -508,6 +510,7 @@ export default [{
],

rules: {
"rsp-rules/faster-node-contains": OFF,
"rsp-rules/no-non-shadow-contains": OFF,
},
}, {
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/calendar/src/useRangeCalendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import {AriaRangeCalendarProps, DateValue} from '@react-types/calendar';
import {CalendarAria, useCalendarBase} from './useCalendarBase';
import {FocusableElement, RefObject} from '@react-types/shared';
import {nodeContains, useEvent} from '@react-aria/utils';
import {isFocusWithin, nodeContains, useEvent} from '@react-aria/utils';
import {RangeCalendarState} from '@react-stately/calendar';
import {useRef} from 'react';

Expand Down Expand Up @@ -52,7 +52,7 @@ export function useRangeCalendar<T extends DateValue>(props: AriaRangeCalendarPr
let target = e.target as Element;
if (
ref.current &&
nodeContains(ref.current, document.activeElement) &&
isFocusWithin(ref.current) &&
(!nodeContains(ref.current, target) || !target.closest('button, [role="button"]'))
) {
state.selectFocusedDate();
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/dialog/src/useDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {AriaDialogProps} from '@react-types/dialog';
import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared';
import {filterDOMProps, nodeContains, useSlotId} from '@react-aria/utils';
import {filterDOMProps, isFocusWithin, useSlotId} from '@react-aria/utils';
import {focusSafely} from '@react-aria/interactions';
import {useEffect, useRef} from 'react';
import {useOverlayFocusContain} from '@react-aria/overlays';
Expand Down Expand Up @@ -40,7 +40,7 @@ export function useDialog(props: AriaDialogProps, ref: RefObject<FocusableElemen

// Focus the dialog itself on mount, unless a child element is already focused.
useEffect(() => {
if (ref.current && !nodeContains(ref.current, document.activeElement)) {
if (ref.current && !isFocusWithin(ref.current)) {
focusSafely(ref.current);

// Safari on iOS does not move the VoiceOver cursor to the dialog
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-aria/grid/src/useGridCell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared';
import {focusSafely, isFocusVisible} from '@react-aria/interactions';
import {getFocusableTreeWalker} from '@react-aria/focus';
import {getScrollParent, mergeProps, nodeContains, scrollIntoViewport} from '@react-aria/utils';
import {getScrollParent, isFocusWithin, mergeProps, nodeContains, scrollIntoViewport} from '@react-aria/utils';
import {GridCollection, GridNode} from '@react-types/grid';
import {gridMap} from './utils';
import {GridState} from '@react-stately/grid';
Expand Down Expand Up @@ -75,7 +75,7 @@ export function useGridCell<T, C extends GridCollection<T>>(props: GridCellProps
let treeWalker = getFocusableTreeWalker(ref.current);
if (focusMode === 'child') {
// If focus is already on a focusable child within the cell, early return so we don't shift focus
if (nodeContains(ref.current, document.activeElement) && ref.current !== document.activeElement) {
if (isFocusWithin(ref.current) && ref.current !== document.activeElement) {
return;
}

Expand All @@ -90,7 +90,7 @@ export function useGridCell<T, C extends GridCollection<T>>(props: GridCellProps

if (
(keyWhenFocused.current != null && node.key !== keyWhenFocused.current) ||
!nodeContains(ref.current, document.activeElement)
!isFocusWithin(ref.current)
) {
focusSafely(ref.current);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/gridlist/src/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {chain, getScrollParent, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils';
import {chain, getScrollParent, isFocusWithin, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils';
import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared';
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
import {getRowId, listMap} from './utils';
Expand Down Expand Up @@ -79,7 +79,7 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
if (
ref.current !== null &&
((keyWhenFocused.current != null && node.key !== keyWhenFocused.current) ||
!nodeContains(ref.current, document.activeElement))
!isFocusWithin(ref.current))
) {
focusSafely(ref.current);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/landmark/src/useLandmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ class LandmarkManager implements LandmarkManagerApi {

private focusMain() {
let main = this.getLandmarkByRole('main');
if (main && main.ref.current && nodeContains(document, main.ref.current)) {
if (main && main.ref.current && main.ref.current.isConnected) {
this.focusLandmark(main.ref.current, 'forward');
return true;
}
Expand All @@ -352,7 +352,7 @@ class LandmarkManager implements LandmarkManagerApi {
}

// Otherwise, focus the landmark itself
if (nextLandmark.ref.current && nodeContains(document, nextLandmark.ref.current)) {
if (nextLandmark.ref.current && nextLandmark.ref.current.isConnected) {
this.focusLandmark(nextLandmark.ref.current, backward ? 'backward' : 'forward');
return true;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/menu/src/useSubmenuTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {AriaMenuItemProps} from './useMenuItem';
import {AriaMenuOptions} from './useMenu';
import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays';
import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared';
import {focusWithoutScrolling, nodeContains, useEvent, useId, useLayoutEffect} from '@react-aria/utils';
import {focusWithoutScrolling, isFocusWithin, nodeContains, useEvent, useId, useLayoutEffect} from '@react-aria/utils';
import type {SubmenuTriggerState} from '@react-stately/menu';
import {useCallback, useRef} from 'react';
import {useLocale} from '@react-aria/i18n';
Expand Down Expand Up @@ -100,7 +100,7 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
let submenuKeyDown = (e: KeyboardEvent) => {
// If focus is not within the menu, assume virtual focus is being used.
// This means some other input element is also within the popover, so we shouldn't close the menu.
if (!nodeContains(e.currentTarget, document.activeElement)) {
if (!isFocusWithin(e.currentTarget)) {
return;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/overlays/src/useOverlayPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {calculatePosition, getRect, PositionResult} from './calculatePosition';
import {DOMAttributes, RefObject} from '@react-types/shared';
import {nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
import {isFocusWithin, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
import {useCallback, useEffect, useRef, useState} from 'react';
import {useCloseOnScroll} from './useCloseOnScroll';
Expand Down Expand Up @@ -154,7 +154,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
// so it can be restored after repositioning. This way if the overlay height
// changes, the focused element appears to stay in the same position.
let anchor: ScrollAnchor | null = null;
if (scrollRef.current && nodeContains(scrollRef.current, document.activeElement)) {
if (scrollRef.current && isFocusWithin(scrollRef.current)) {
let anchorRect = document.activeElement?.getBoundingClientRect();
let scrollRect = scrollRef.current.getBoundingClientRect();
// Anchor from the top if the offset is in the top half of the scrollable element,
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-aria/selection/src/useSelectableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isTabbable, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils';
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isFocusWithin, isTabbable, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils';
import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus';
import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared';
import {flushSync} from 'react-dom';
Expand Down Expand Up @@ -314,7 +314,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
// If the active element is NOT tabbable but is contained by an element that IS tabbable (aka the cell), the browser will actually move focus to
// the containing element. We need to special case this so that tab will move focus out of the grid instead of looping between
// focusing the containing cell and back to the non-tabbable child element
if (next && (!nodeContains(next, document.activeElement) || (document.activeElement && !isTabbable(document.activeElement)))) {
if (next && (!isFocusWithin(next) || (document.activeElement && !isTabbable(document.activeElement)))) {
focusWithoutScrolling(next);
}
}
Expand Down Expand Up @@ -379,7 +379,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
let element = getItemElement(ref, manager.focusedKey);
if (element instanceof HTMLElement) {
// This prevents a flash of focus on the first/last element in the collection, or the collection itself.
if (!nodeContains(element, document.activeElement) && !shouldUseVirtualFocus) {
if (!isFocusWithin(element) && !shouldUseVirtualFocus) {
focusWithoutScrolling(element);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
export {useId, mergeIds, useSlotId} from './useId';
export {chain} from './chain';
export {createShadowTreeWalker, ShadowTreeWalker} from './shadowdom/ShadowTreeWalker';
export {getActiveElement, getEventTarget, nodeContains} from './shadowdom/DOMFunctions';
export {getActiveElement, getEventTarget, nodeContains, isFocusWithin} from './shadowdom/DOMFunctions';
export {getOwnerDocument, getOwnerWindow, isShadowRoot} from './domHelpers';
export {mergeProps} from './mergeProps';
export {mergeRefs} from './mergeRefs';
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/utils/src/scrollIntoView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'|
* the body (e.g. targetElement is in a popover), this will only scroll the scroll parents of the targetElement up to but not including the body itself.
*/
export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollIntoViewportOpts): void {
if (targetElement && nodeContains(document, targetElement)) {
if (targetElement && targetElement.isConnected) {
let root = document.scrollingElement || document.documentElement;
let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden';
// If scrolling is not currently prevented then we aren’t in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
Expand Down
23 changes: 22 additions & 1 deletion packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16
/* eslint-disable rsp-rules/no-non-shadow-contains */

import {isShadowRoot} from '../domHelpers';
import {getOwnerWindow, isShadowRoot} from '../domHelpers';
import {shadowDOM} from '@react-stately/flags';

/**
Expand Down Expand Up @@ -69,3 +69,24 @@ export function getEventTarget<T extends Event>(event: T): Element {
}
return event.target as Element;
}

/**
* ShadowDOM safe fast version of node.contains(document.activeElement).
* @param node
* @returns
*/
export function isFocusWithin(node: Element | null | undefined): boolean {
if (!node) {
return false;
}
// Get the active element within the node's parent shadow root (or the document). Can return null.
let root = node.getRootNode();
let ownerWindow = getOwnerWindow(node);
if (!(root instanceof ownerWindow.Document || root instanceof ownerWindow.ShadowRoot)) {
return false;
}
let activeElement = root.activeElement;

// Check if the active element is within this node. These nodes are within the same shadow root.
return activeElement != null && node.contains(activeElement);
}
4 changes: 2 additions & 2 deletions packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {DOMRefValue, ItemProps, Key} from '@react-types/shared';
import {FocusScope} from '@react-aria/focus';
import {getInteractionModality} from '@react-aria/interactions';
import helpStyles from '@adobe/spectrum-css-temp/components/contextualhelp/vars.css';
import {nodeContains} from '@react-aria/utils';
import {isFocusWithin, nodeContains} from '@react-aria/utils';
import {Popover} from './Popover';
import React, {JSX, KeyboardEventHandler, ReactElement, useEffect, useRef, useState} from 'react';
import ReactDOM from 'react-dom';
Expand Down Expand Up @@ -99,7 +99,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem
setTraySubmenuAnimation('spectrum-TraySubmenu-exit');
setTimeout(() => {
submenuTriggerState.close();
if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) {
if (parentMenuRef.current && !isFocusWithin(parentMenuRef.current)) {
parentMenuRef.current.focus();
}
}, 220); // Matches transition duration
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-spectrum/menu/src/SubmenuTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
*/

import {classNames, useIsMobileDevice} from '@react-spectrum/utils';
import {isFocusWithin, mergeProps} from '@react-aria/utils';
import {Key} from '@react-types/shared';
import {MenuContext, SubmenuTriggerContext, useMenuStateContext} from './context';
import {mergeProps, nodeContains} from '@react-aria/utils';
import {Popover} from './Popover';
import React, {type JSX, ReactElement, useRef} from 'react';
import ReactDOM from 'react-dom';
Expand Down Expand Up @@ -49,7 +49,7 @@ function SubmenuTrigger(props: SubmenuTriggerProps) {
let isMobile = useIsMobileDevice();
let onBackButtonPress = () => {
submenuTriggerState.close();
if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) {
if (parentMenuRef.current && !isFocusWithin(parentMenuRef.current)) {
parentMenuRef.current.focus();
}
};
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-spectrum/menu/src/useOverlayPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {calculatePosition, getRect, PositionResult} from './calculatePosition';
import {DOMAttributes, RefObject} from '@react-types/shared';
import {nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
import {isFocusWithin, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
import {useCallback, useEffect, useRef, useState} from 'react';
import {useCloseOnScroll} from './useCloseOnScroll';
Expand Down Expand Up @@ -154,7 +154,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
// so it can be restored after repositioning. This way if the overlay height
// changes, the focused element appears to stay in the same position.
let anchor: ScrollAnchor | null = null;
if (scrollRef.current && nodeContains(scrollRef.current, document.activeElement)) {
if (scrollRef.current && isFocusWithin(scrollRef.current)) {
let anchorRect = document.activeElement?.getBoundingClientRect();
let scrollRect = scrollRef.current.getBoundingClientRect();
// Anchor from the top if the offset is in the top half of the scrollable element,
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-spectrum/s2/src/TableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg';
import {ColumnSize} from '@react-types/table';
import {CustomDialog, DialogContainer} from '..';
import {DOMProps, DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LinkDOMProps, LoadingState, Node} from '@react-types/shared';
import {getActiveElement, getOwnerDocument, nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils';
import {getActiveElement, getOwnerDocument, isFocusWithin, nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils';
import {GridNode} from '@react-types/grid';
import {IconContext} from './Icon';
// @ts-ignore
Expand Down Expand Up @@ -1301,7 +1301,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean,
onOpenChange={setIsOpen}
ref={popoverRef}
shouldCloseOnInteractOutside={() => {
if (!nodeContains(popoverRef.current, document.activeElement)) {
if (!isFocusWithin(popoverRef.current)) {
return false;
}
formRef.current?.requestSubmit();
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-spectrum/table/src/TableViewBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {GridNode} from '@react-types/grid';
import {InsertionIndicator} from './InsertionIndicator';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {isAndroid, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils';
import {isAndroid, isFocusWithin, mergeProps, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils';
import {Item, Menu, MenuTrigger} from '@react-spectrum/menu';
import {LayoutInfo, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer';
import {layoutInfoToStyle, ScrollView, setScrollLeft, VirtualizerItem} from '@react-aria/virtualizer';
Expand Down Expand Up @@ -606,7 +606,7 @@ function TableVirtualizer<T>(props: TableVirtualizerProps<T>) {
// only that it changes in a resize, and when that happens, we want to sync the body to the
// header scroll position
useEffect(() => {
if (getInteractionModality() === 'keyboard' && headerRef.current && nodeContains(headerRef.current, document.activeElement) && bodyRef.current) {
if (getInteractionModality() === 'keyboard' && headerRef.current && isFocusWithin(headerRef.current) && bodyRef.current) {
scrollIntoView(headerRef.current, document.activeElement as HTMLElement);
scrollIntoViewport(document.activeElement, {containingElement: domRef.current});
bodyRef.current.scrollLeft = headerRef.current.scrollLeft;
Expand Down
4 changes: 3 additions & 1 deletion packages/dev/eslint-plugin-rsp-rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import actEventsTest from './rules/act-events-test.js';
import fasterNodeContains from './rules/faster-node-contains.js';
import noGetByRoleToThrow from './rules/no-getByRole-toThrow.js';
import noNonShadowContains from './rules/no-non-shadow-contains.js';
import noReactKey from './rules/no-react-key.js';
Expand All @@ -21,7 +22,8 @@ const rules = {
'no-getByRole-toThrow': noGetByRoleToThrow,
'no-react-key': noReactKey,
'sort-imports': sortImports,
'no-non-shadow-contains': noNonShadowContains
'no-non-shadow-contains': noNonShadowContains,
'faster-node-contains': fasterNodeContains
};

const meta = {
Expand Down
Loading