diff --git a/eslint.config.mjs b/eslint.config.mjs index cd41e502778..5c3ba99d16c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -250,6 +250,7 @@ export default [{ "rsp-rules/no-react-key": [ERROR], "rsp-rules/sort-imports": [ERROR], "rsp-rules/no-non-shadow-contains": [ERROR], + "rsp-rules/shadow-safe-active-element": [ERROR], "rulesdir/imports": [ERROR], "rulesdir/useLayoutEffectRule": [ERROR], "rulesdir/pure-render": [ERROR], @@ -430,6 +431,7 @@ export default [{ "rsp-rules/act-events-test": ERROR, "rsp-rules/no-getByRole-toThrow": ERROR, "rsp-rules/no-non-shadow-contains": OFF, + "rsp-rules/shadow-safe-active-element": OFF, "rulesdir/imports": OFF, "monorepo/no-internal-import": OFF, "jsdoc/require-jsdoc": OFF @@ -509,6 +511,7 @@ export default [{ rules: { "rsp-rules/no-non-shadow-contains": OFF, + "rsp-rules/shadow-safe-active-element": OFF, }, }, { files: ["packages/@react-spectrum/s2/**", "packages/dev/s2-docs/**"], diff --git a/packages/@react-aria/calendar/src/useCalendarCell.ts b/packages/@react-aria/calendar/src/useCalendarCell.ts index 9db8185c699..8dc2e8f53a4 100644 --- a/packages/@react-aria/calendar/src/useCalendarCell.ts +++ b/packages/@react-aria/calendar/src/useCalendarCell.ts @@ -13,7 +13,7 @@ import {CalendarDate, isEqualDay, isSameDay, isToday} from '@internationalized/date'; import {CalendarState, RangeCalendarState} from '@react-stately/calendar'; import {DOMAttributes, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, getScrollParent, mergeProps, scrollIntoViewport, useDeepMemo, useDescription} from '@react-aria/utils'; +import {focusWithoutScrolling, getActiveElement, getScrollParent, mergeProps, scrollIntoViewport, useDeepMemo, useDescription} from '@react-aria/utils'; import {getEraFormat, hookData} from './utils'; import {getInteractionModality, usePress} from '@react-aria/interactions'; // @ts-ignore @@ -291,7 +291,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() === ref.current) { scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)}); } } diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index f228c77b477..d9af1495934 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -13,7 +13,7 @@ import {AriaRangeCalendarProps, DateValue} from '@react-types/calendar'; import {CalendarAria, useCalendarBase} from './useCalendarBase'; import {FocusableElement, RefObject} from '@react-types/shared'; -import {nodeContains, useEvent} from '@react-aria/utils'; +import {getActiveElement, nodeContains, useEvent} from '@react-aria/utils'; import {RangeCalendarState} from '@react-stately/calendar'; import {useRef} from 'react'; @@ -52,7 +52,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr let target = e.target as Element; if ( ref.current && - nodeContains(ref.current, document.activeElement) && + nodeContains(ref.current, getActiveElement()) && (!nodeContains(ref.current, target) || !target.closest('button, [role="button"]')) ) { state.selectFocusedDate(); diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index 9e77f840f51..4ed65b82fdb 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -12,7 +12,7 @@ import {CalendarDate, toCalendar} from '@internationalized/date'; import {DateFieldState, DateSegment} from '@react-stately/datepicker'; -import {getScrollParent, isIOS, isMac, mergeProps, nodeContains, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils'; +import {getActiveElement, 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'; @@ -311,7 +311,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() === 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 eef23f9968c..6f07b0695b0 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, nodeContains, 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 && !nodeContains(ref.current, document.activeElement)) { + if (ref.current && !nodeContains(ref.current, getActiveElement())) { 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() === ref.current || getActiveElement() === 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 9895aaa818a..a3d63a6d049 100644 --- a/packages/@react-aria/dnd/src/DragManager.ts +++ b/packages/@react-aria/dnd/src/DragManager.ts @@ -13,8 +13,8 @@ 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 {getActiveElement, isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils'; import {getDragModality, getTypes} from './utils'; -import {isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils'; import type {LocalizedStringFormatter} from '@internationalized/string'; import {RefObject, useEffect, useState} from 'react'; @@ -570,7 +570,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()?.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); } this.setCurrentDropTarget(null); @@ -584,7 +584,7 @@ class DragSession { } // Re-trigger focus event on active element, since it will not have received it during dragging (see cancelEvent). - document.activeElement?.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + getActiveElement()?.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); announce(this.stringFormatter.format('dropCanceled')); } diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index a7ee80f5ec6..2e9e03934e5 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -12,8 +12,8 @@ import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared'; import {focusSafely, isFocusVisible} from '@react-aria/interactions'; +import {getActiveElement, getScrollParent, mergeProps, nodeContains, scrollIntoViewport} from '@react-aria/utils'; import {getFocusableTreeWalker} from '@react-aria/focus'; -import {getScrollParent, mergeProps, nodeContains, scrollIntoViewport} from '@react-aria/utils'; import {GridCollection, GridNode} from '@react-types/grid'; import {gridMap} from './utils'; import {GridState} from '@react-stately/grid'; @@ -75,7 +75,7 @@ export function useGridCell>(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 (nodeContains(ref.current, getActiveElement()) && ref.current !== getActiveElement()) { return; } @@ -90,7 +90,7 @@ export function useGridCell>(props: GridCellProps if ( (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !nodeContains(ref.current, document.activeElement) + !nodeContains(ref.current, getActiveElement()) ) { focusSafely(ref.current); } @@ -109,12 +109,13 @@ export function useGridCell>(props: GridCellProps }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!nodeContains(e.currentTarget, e.target as Element) || state.isKeyboardNavigationDisabled || !ref.current || !document.activeElement) { + let activeElement = getActiveElement(); + if (!nodeContains(e.currentTarget, e.target 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': { @@ -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() === ref.current) { focus(); } }); diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 6a1bd8e27c1..e3362d471b1 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getScrollParent, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; +import {chain, getActiveElement, 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) || - !nodeContains(ref.current, document.activeElement)) + !nodeContains(ref.current, getActiveElement())) ) { focusSafely(ref.current); } @@ -131,14 +131,15 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) { + let activeElement = getActiveElement(); + if (!nodeContains(e.currentTarget, e.target 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 && activeElement === 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(); @@ -244,7 +245,8 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }; let onKeyDown = (e) => { - if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) { + let activeElement = getActiveElement(); + if (!nodeContains(e.currentTarget, e.target 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/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index 07626782dea..d1dadaf1199 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -15,7 +15,7 @@ // NOTICE file in the root directory of this source tree. // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions -import {getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, openLink} from '@react-aria/utils'; +import {getActiveElement, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, openLink} from '@react-aria/utils'; import {ignoreFocusEvent} from './utils'; import {PointerType} from '@react-types/shared'; import {useEffect, useState} from 'react'; @@ -310,10 +310,11 @@ function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: Handl // 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 + let activeElement = getActiveElement(document); 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/utils.ts b/packages/@react-aria/interactions/src/utils.ts index 46321a1d4a8..a0dff60f6a4 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getOwnerWindow, isFocusable, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getActiveElement, getOwnerWindow, isFocusable, useLayoutEffect} from '@react-aria/utils'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; // Turn a native event into a React synthetic event. @@ -84,7 +84,7 @@ 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 relatedTargetEl = target === getActiveElement() ? null : getActiveElement(); target.dispatchEvent(new FocusEvent('blur', {relatedTarget: relatedTargetEl})); target.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget: relatedTargetEl})); } diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 14df02c2243..84d4c9a5f02 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -14,7 +14,7 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays'; import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, nodeContains, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getActiveElement, nodeContains, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; import type {SubmenuTriggerState} from '@react-stately/menu'; import {useCallback, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -100,7 +100,7 @@ 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 (!nodeContains(e.currentTarget, document.activeElement)) { + if (!nodeContains(e.currentTarget, getActiveElement())) { return; } @@ -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() === 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() === ref?.current) { focusWithoutScrolling(submenuRef.current); } } else if (state.isOpen) { diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 670d79d69e0..acb9ed31b33 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, 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() === inputRef.current) { return; } diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index ee218a5b7ca..2ed097906ee 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -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 {getActiveElement, nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; @@ -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 && nodeContains(scrollRef.current, document.activeElement)) { - let anchorRect = document.activeElement?.getBoundingClientRect(); + if (scrollRef.current && nodeContains(scrollRef.current, getActiveElement())) { + let anchorRect = getActiveElement()?.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. @@ -208,8 +208,9 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { overlay.style.maxHeight = position.maxHeight != null ? position.maxHeight + 'px' : ''; // Restore scroll position relative to anchor element. - if (anchor && document.activeElement && scrollRef.current) { - let anchorRect = document.activeElement.getBoundingClientRect(); + let activeElement = getActiveElement(); + 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 c8687d33b97..ebcd8a4d88d 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, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils'; interface PreventScrollOptions { /** Whether the scroll lock is disabled. */ @@ -88,7 +88,7 @@ function preventScrollStandard() { // by preventing default in a `touchmove` event. This is best effort: we can't prevent default when pinch // zooming or when an element contains text selection, which may allow scrolling in some cases. // 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves. -// 4. When focus moves to an input, create an off screen input and focus that temporarily. This prevents +// 4. When focus moves to an input, create an off screen input and focus that temporarily. This prevents // Safari from scrolling the page. After a small delay, focus the real input and scroll it into view // ourselves, without scrolling the whole page. function preventScrollMobileSafari() { @@ -99,7 +99,7 @@ function preventScrollMobileSafari() { let target = e.target as Element; scrollable = isScrollable(target) ? target : getScrollParent(target, true); allowTouchMove = false; - + // If the target is selected, don't preventDefault in touchmove to allow user to adjust selection. let selection = target.ownerDocument.defaultView!.getSelection(); if (selection && !selection.isCollapsed && selection.containsNode(target, true)) { @@ -116,7 +116,7 @@ function preventScrollMobileSafari() { // If this is a focused input element with a selected range, allow user to drag the selection handles. if ( - 'selectionStart' in target && + 'selectionStart' in target && 'selectionEnd' in target && (target.selectionStart as number) < (target.selectionEnd as number) && target.ownerDocument.activeElement === target @@ -183,7 +183,8 @@ function preventScrollMobileSafari() { let focus = HTMLElement.prototype.focus; HTMLElement.prototype.focus = function (opts) { // Track whether the keyboard was already visible before. - let wasKeyboardVisible = document.activeElement != null && willOpenKeyboard(document.activeElement); + let activeElement = getActiveElement(); + 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/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 7407e576d61..f4c5d4ca94b 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -314,7 +314,8 @@ 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)))) { + let activeElement = getActiveElement(); + if (next && (!nodeContains(next, activeElement) || (activeElement && !isTabbable(activeElement)))) { focusWithoutScrolling(next); } } @@ -379,7 +380,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 (!nodeContains(element, getActiveElement()) && !shouldUseVirtualFocus) { focusWithoutScrolling(element); } diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index 207a72e07ba..e5d673e43b6 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, 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() !== ref.current && ref.current) { focusSafely(ref.current); } } else { diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 7d02274c946..65dca82f8e8 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -14,11 +14,11 @@ import {ChangeEvent, useCallback, useEffect, useRef} from 'react'; import {ColumnSize} from '@react-types/table'; import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared'; import {focusSafely, useInteractionModality, useKeyboard, useMove, usePress} from '@react-aria/interactions'; +import {getActiveElement, mergeProps, useDescription, useEffectEvent, useId} from '@react-aria/utils'; 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 {TableColumnResizeState} from '@react-stately/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; @@ -198,7 +198,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let startResizeEvent = useEffectEvent(startResize); useEffect(() => { if (prevResizingColumn.current !== resizingColumn && resizingColumn != null && resizingColumn === item.key) { - wasFocusedOnResizeStart.current = document.activeElement === ref.current; + wasFocusedOnResizeStart.current = getActiveElement() === ref.current; startResizeEvent(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/toolbar/src/useToolbar.ts b/packages/@react-aria/toolbar/src/useToolbar.ts index 7331973d8f9..fa0502aece9 100644 --- a/packages/@react-aria/toolbar/src/useToolbar.ts +++ b/packages/@react-aria/toolbar/src/useToolbar.ts @@ -12,7 +12,7 @@ import {AriaLabelingProps, Orientation, RefObject} from '@react-types/shared'; import {createFocusManager} from '@react-aria/focus'; -import {filterDOMProps, nodeContains, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, getActiveElement, nodeContains, useLayoutEffect} from '@react-aria/utils'; import {HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -81,7 +81,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject { - if (!document.activeElement || !willOpenKeyboard(document.activeElement)) { + let activeElement = getActiveElement(); + if (!activeElement || !willOpenKeyboard(activeElement)) { updateSize({width: document.documentElement.clientWidth, height: document.documentElement.clientHeight}); } }); diff --git a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx index 242a1e0c91b..d27c1567655 100644 --- a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx +++ b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx @@ -21,11 +21,11 @@ import {Field} from '@react-spectrum/label'; import {FocusableRef, ValidationState} from '@react-types/shared'; import {focusSafely, setInteractionModality, useHover} from '@react-aria/interactions'; import {FocusScope, useFocusRing} from '@react-aria/focus'; +import {getActiveElement, mergeProps, useFormReset, useId} from '@react-aria/utils'; // @ts-ignore 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 {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() !== inputRef.current || !isTouchDown.current) { return; } diff --git a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx index 5954ab301a3..804fe66d52a 100644 --- a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx +++ b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx @@ -24,11 +24,11 @@ import {Field} from '@react-spectrum/label'; import {FocusableRef, FocusableRefValue, ValidationState} from '@react-types/shared'; import {FocusRing, FocusScope} from '@react-aria/focus'; import {focusSafely, setInteractionModality, useHover} from '@react-aria/interactions'; +import {getActiveElement, mergeProps, useFormReset, useId, useObjectRef} from '@react-aria/utils'; // @ts-ignore 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 {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() !== inputRef.current || !isTouchDown.current) { return; } diff --git a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx index 54051eede48..88f61c1e073 100644 --- a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx +++ b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx @@ -13,9 +13,9 @@ import {classNames, SlotProvider, unwrapDOMRef, useIsMobileDevice} from '@react-spectrum/utils'; import {DOMRefValue, ItemProps, Key} from '@react-types/shared'; import {FocusScope} from '@react-aria/focus'; +import {getActiveElement, nodeContains} from '@react-aria/utils'; import {getInteractionModality} from '@react-aria/interactions'; import helpStyles from '@adobe/spectrum-css-temp/components/contextualhelp/vars.css'; -import {nodeContains} from '@react-aria/utils'; import {Popover} from './Popover'; import React, {JSX, KeyboardEventHandler, ReactElement, useEffect, useRef, useState} from 'react'; import ReactDOM from 'react-dom'; @@ -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 && !nodeContains(parentMenuRef.current, getActiveElement())) { 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 061e40d55b7..64bcd8d4e2b 100644 --- a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx @@ -11,9 +11,9 @@ */ import {classNames, useIsMobileDevice} from '@react-spectrum/utils'; +import {getActiveElement, mergeProps, nodeContains} 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'; @@ -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 && !nodeContains(parentMenuRef.current, getActiveElement())) { parentMenuRef.current.focus(); } }; diff --git a/packages/@react-spectrum/menu/src/useOverlayPosition.ts b/packages/@react-spectrum/menu/src/useOverlayPosition.ts index ee218a5b7ca..2ed097906ee 100644 --- a/packages/@react-spectrum/menu/src/useOverlayPosition.ts +++ b/packages/@react-spectrum/menu/src/useOverlayPosition.ts @@ -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 {getActiveElement, nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; @@ -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 && nodeContains(scrollRef.current, document.activeElement)) { - let anchorRect = document.activeElement?.getBoundingClientRect(); + if (scrollRef.current && nodeContains(scrollRef.current, getActiveElement())) { + let anchorRect = getActiveElement()?.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. @@ -208,8 +208,9 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { overlay.style.maxHeight = position.maxHeight != null ? position.maxHeight + 'px' : ''; // Restore scroll position relative to anchor element. - if (anchor && document.activeElement && scrollRef.current) { - let anchorRect = document.activeElement.getBoundingClientRect(); + let activeElement = getActiveElement(); + 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-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index b5e66fd84a2..cb35abc8c33 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -1301,7 +1301,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, onOpenChange={setIsOpen} ref={popoverRef} shouldCloseOnInteractOutside={() => { - if (!nodeContains(popoverRef.current, document.activeElement)) { + if (!nodeContains(popoverRef.current, getActiveElement())) { return false; } formRef.current?.requestSubmit(); diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 443303f5fb2..3ab2b68b7f6 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -28,12 +28,12 @@ import type {DragAndDropHooks} from '@react-spectrum/dnd'; import type {DraggableCollectionState, DroppableCollectionState} from '@react-stately/dnd'; import type {DraggableItemResult, DropIndicatorAria, DroppableCollectionResult} from '@react-aria/dnd'; import {FocusRing, FocusScope, useFocusRing} from '@react-aria/focus'; +import {getActiveElement, isAndroid, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; import {getInteractionModality, HoverProps, isFocusVisible, useHover, usePress} from '@react-aria/interactions'; 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 {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,9 @@ 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 && nodeContains(headerRef.current, document.activeElement) && bodyRef.current) { - scrollIntoView(headerRef.current, document.activeElement as HTMLElement); - scrollIntoViewport(document.activeElement, {containingElement: domRef.current}); + if (getInteractionModality() === 'keyboard' && headerRef.current && nodeContains(headerRef.current, getActiveElement()) && bodyRef.current) { + scrollIntoView(headerRef.current, getActiveElement() as HTMLElement); + scrollIntoViewport(getActiveElement(), {containingElement: domRef.current}); bodyRef.current.scrollLeft = headerRef.current.scrollLeft; } }, [state.contentSize, headerRef, bodyRef, domRef]); diff --git a/packages/dev/eslint-plugin-rsp-rules/index.js b/packages/dev/eslint-plugin-rsp-rules/index.js index 4bb1265f0fc..c7c49e404a8 100644 --- a/packages/dev/eslint-plugin-rsp-rules/index.js +++ b/packages/dev/eslint-plugin-rsp-rules/index.js @@ -14,6 +14,7 @@ import actEventsTest from './rules/act-events-test.js'; import noGetByRoleToThrow from './rules/no-getByRole-toThrow.js'; import noNonShadowContains from './rules/no-non-shadow-contains.js'; import noReactKey from './rules/no-react-key.js'; +import shadowSafeActiveElement from './rules/shadow-safe-active-element.js'; import sortImports from './rules/sort-imports.js'; const rules = { @@ -21,7 +22,8 @@ const rules = { 'no-getByRole-toThrow': noGetByRoleToThrow, 'no-react-key': noReactKey, 'sort-imports': sortImports, - 'no-non-shadow-contains': noNonShadowContains + 'no-non-shadow-contains': noNonShadowContains, + 'shadow-safe-active-element': shadowSafeActiveElement }; const meta = { diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-active-element.js b/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-active-element.js new file mode 100644 index 00000000000..3ed4d96a34f --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-active-element.js @@ -0,0 +1,108 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const plugin = { + meta: { + type: 'suggestion', + docs: { + description: 'Disallow using document.activeElement in favor of getActiveElement() for shadow DOM compatibility', + recommended: true + }, + fixable: 'code', + messages: { + useGetActiveElement: 'Use getActiveElement() instead of document.activeElement for shadow DOM compatibility.' + } + }, + create: (context) => { + let hasGetActiveElementImport = false; + let getActiveElementLocalName = 'getActiveElement'; + let existingReactAriaUtilsImport = null; + + return { + // Track imports from @react-aria/utils + ImportDeclaration(node) { + if ( + node.source && + node.source.type === 'Literal' && + node.source.value === '@react-aria/utils' + ) { + existingReactAriaUtilsImport = node; + // Check if getActiveElement is already imported + const hasGetActiveElement = node.specifiers.some( + spec => spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'getActiveElement' + ); + if (hasGetActiveElement) { + hasGetActiveElementImport = true; + const getActiveElementSpec = node.specifiers.find( + spec => spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'getActiveElement' + ); + getActiveElementLocalName = getActiveElementSpec.local.name; + } + } + }, + + // Detect document.activeElement usage + ['MemberExpression[object.name=\'document\'][property.name=\'activeElement\']'](node) { + context.report({ + node, + messageId: 'useGetActiveElement', + fix: (fixer) => { + const fixes = []; + const sourceCode = context.sourceCode; + + // Replace document.activeElement with getActiveElement() + fixes.push(fixer.replaceText(node, `${getActiveElementLocalName}()`)); + + // Add import if not present + if (!hasGetActiveElementImport) { + if (existingReactAriaUtilsImport) { + // Add getActiveElement to existing @react-aria/utils import + const specifiers = existingReactAriaUtilsImport.specifiers; + if (specifiers.length > 0) { + fixes.push(fixer.insertTextAfter( + sourceCode.getFirstToken(existingReactAriaUtilsImport, token => token.value === '{'), + 'getActiveElement, ' + )); + } + } else { + // No existing import from @react-aria/utils, create a new one + const programNode = context.sourceCode.ast; + const imports = programNode.body.filter(node => node.type === 'ImportDeclaration'); + + if (imports.length > 0) { + const lastImport = imports[imports.length - 1]; + const importStatement = '\nimport {getActiveElement} from \'@react-aria/utils\';'; + fixes.push(fixer.insertTextAfter(lastImport, importStatement)); + } else { + // No imports, add at the beginning + const importStatement = 'import {getActiveElement} from \'@react-aria/utils\';\n'; + fixes.push(fixer.insertTextBefore(programNode.body[0], importStatement)); + } + } + + // Mark as imported for subsequent fixes in the same file + hasGetActiveElementImport = true; + } + + return fixes; + } + }); + } + }; + } +}; + +export default plugin; diff --git a/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-active-element.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-active-element.test-lint.js new file mode 100644 index 00000000000..54a0b575df7 --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-active-element.test-lint.js @@ -0,0 +1,61 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {RuleTester} from 'eslint'; +import shadowSafeActiveElementRule from '../rules/shadow-safe-active-element.js'; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2015, + sourceType: 'module' + } +}); + +// Throws error if the tests in ruleTester.run() do not pass +ruleTester.run( + 'shadow-safe-active-element', + shadowSafeActiveElementRule, + { + // 'valid' checks cases that should pass + valid: [ + { + code: ` +import {getActiveElement} from '@react-aria/utils'; +if (getActiveElement()) { + console.log('active element'); +}` + }, + { + code: ` +import {getActiveElement} from '@react-aria/utils'; +if (getActiveElement(element)) { + console.log('active element'); +}` + } + ], + // 'invalid' checks cases that should not pass + invalid: [ + { + code: ` +if (document.activeElement) { + console.log('active element'); +}`, + output: ` +import {getActiveElement} from '@react-aria/utils'; +if (getActiveElement()) { + console.log('active element'); +}`, + errors: 1 + } + ] + } +); diff --git a/packages/dev/s2-docs/src/SearchMenuTrigger.tsx b/packages/dev/s2-docs/src/SearchMenuTrigger.tsx index 2051efcb829..d83dfc91021 100644 --- a/packages/dev/s2-docs/src/SearchMenuTrigger.tsx +++ b/packages/dev/s2-docs/src/SearchMenuTrigger.tsx @@ -2,6 +2,7 @@ import {Button, ButtonProps, Modal, ModalOverlay} from 'react-aria-components'; import {fontRelative, lightDark, style} from '@react-spectrum/s2/style' with { type: 'macro' }; +import {getActiveElement} from '@react-aria/utils'; import {getLibraryFromPage, getLibraryLabel} from './library'; import {Provider, Button as S2Button, ButtonProps as S2ButtonProps} from '@react-spectrum/s2'; import React, {lazy, useCallback, useEffect, useRef, useState} from 'react'; @@ -103,7 +104,7 @@ export default function SearchMenuTrigger({onOpen, onClose, isSearchOpen, overla } else if (((e.key === 'k' && (isMac ? e.metaKey : e.ctrlKey)))) { e.preventDefault(); open(''); - } else if (e.key === '/' && !(isTextInputLike(e.target as Element | null) || isTextInputLike(document.activeElement))) { + } else if (e.key === '/' && !(isTextInputLike(e.target as Element | null) || isTextInputLike(getActiveElement()))) { e.preventDefault(); open(''); } diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index 144f8515b44..6b4421ac601 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -21,7 +21,7 @@ import { useContextProps, useRenderProps } from './utils'; -import {filterDOMProps, mergeProps, nodeContains, 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'; @@ -199,7 +199,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 && !nodeContains(ref.current, document.activeElement)) { + if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !nodeContains(ref.current, getActiveElement())) { focusSafely(ref.current); } }, [isDialog, ref, props.trigger]);