diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts index d9ec3727ce2..6f0e3114433 100644 --- a/packages/@react-aria/dnd/src/DragManager.ts +++ b/packages/@react-aria/dnd/src/DragManager.ts @@ -408,7 +408,7 @@ class DragSession { this.dragTarget.element, ...validDropItems.flatMap(item => item.activateButtonRef?.current ? [item.element, item.activateButtonRef?.current] : [item.element]), ...visibleDropTargets.flatMap(target => target.activateButtonRef?.current ? [target.element, target.activateButtonRef?.current] : [target.element]) - ]); + ], {shouldUseInert: true}); this.mutationObserver.observe(document.body, {subtree: true, attributes: true, attributeFilter: ['aria-hidden']}); } diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 6f04afe7720..6c5a3d2c7c5 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -24,7 +24,6 @@ import { } from '@react-aria/utils'; import {FocusableElement, RefObject} from '@react-types/shared'; import {focusSafely, getInteractionModality} from '@react-aria/interactions'; -import {isElementVisible} from './isElementVisible'; import React, {JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; export interface FocusScopeProps { @@ -758,7 +757,6 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions } if (filter(node as Element) - && isElementVisible(node as Element) && (!scope || isElementInScope(node as Element, scope)) && (!opts?.accept || opts.accept(node as Element)) ) { diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index b7b2a9586de..21470304752 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -10,6 +10,15 @@ * governing permissions and limitations under the License. */ +import {getOwnerWindow} from '@react-aria/utils'; + +const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype; + +interface AriaHideOutsideOptions { + root?: Element, + shouldUseInert?: boolean +} + // Keeps a ref count of all hidden elements. Added to when hiding an element, and // subtracted from when showing it again. When it reaches zero, aria-hidden is removed. let refCountMap = new WeakMap(); @@ -29,10 +38,28 @@ let observerStack: Array = []; * @param root - Nothing will be hidden above this element. * @returns - A function to restore all hidden elements. */ -export function ariaHideOutside(targets: Element[], root = document.body) { +export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOptions | Element) { + let windowObj = getOwnerWindow(targets?.[0]); + let opts = options instanceof windowObj.Element ? {root: options} : options; + let root = opts?.root ?? document.body; + let shouldUseInert = opts?.shouldUseInert && supportsInert; let visibleNodes = new Set(targets); let hiddenNodes = new Set(); + let getHidden = (element: Element) => { + return shouldUseInert && element instanceof windowObj.HTMLElement ? element.inert : element.getAttribute('aria-hidden') === 'true'; + }; + + let setHidden = (element: Element, hidden: boolean) => { + if (shouldUseInert && element instanceof windowObj.HTMLElement) { + element.inert = hidden; + } else if (hidden) { + element.setAttribute('aria-hidden', 'true'); + } else { + element.removeAttribute('aria-hidden'); + } + }; + let walk = (root: Element) => { // Keep live announcer and top layer elements (e.g. toasts) visible. for (let element of root.querySelectorAll('[data-live-announcer], [data-react-aria-top-layer]')) { @@ -87,12 +114,12 @@ export function ariaHideOutside(targets: Element[], root = document.body) { // If already aria-hidden, and the ref count is zero, then this element // was already hidden and there's nothing for us to do. - if (node.getAttribute('aria-hidden') === 'true' && refCount === 0) { + if (getHidden(node) && refCount === 0) { return; } if (refCount === 0) { - node.setAttribute('aria-hidden', 'true'); + setHidden(node, true); } hiddenNodes.add(node); @@ -161,7 +188,7 @@ export function ariaHideOutside(targets: Element[], root = document.body) { continue; } if (count === 1) { - node.removeAttribute('aria-hidden'); + setHidden(node, false); refCountMap.delete(node); } else { refCountMap.set(node, count - 1); diff --git a/packages/@react-aria/overlays/src/useModalOverlay.ts b/packages/@react-aria/overlays/src/useModalOverlay.ts index 4f95655a60b..c0fbb70a0b1 100644 --- a/packages/@react-aria/overlays/src/useModalOverlay.ts +++ b/packages/@react-aria/overlays/src/useModalOverlay.ts @@ -58,7 +58,7 @@ export function useModalOverlay(props: AriaModalOverlayProps, state: OverlayTrig useEffect(() => { if (state.isOpen && ref.current) { - return ariaHideOutside([ref.current]); + return ariaHideOutside([ref.current], {shouldUseInert: true}); } }, [state.isOpen, ref]); diff --git a/packages/@react-aria/overlays/src/usePopover.ts b/packages/@react-aria/overlays/src/usePopover.ts index 068c67e7bf4..281c3509b8e 100644 --- a/packages/@react-aria/overlays/src/usePopover.ts +++ b/packages/@react-aria/overlays/src/usePopover.ts @@ -13,9 +13,10 @@ import {ariaHideOutside, keepVisible} from './ariaHideOutside'; import {AriaPositionProps, useOverlayPosition} from './useOverlayPosition'; import {DOMAttributes, RefObject} from '@react-types/shared'; -import {mergeProps, useLayoutEffect} from '@react-aria/utils'; +import {mergeProps} from '@react-aria/utils'; import {OverlayTriggerState} from '@react-stately/overlays'; import {PlacementAxis} from '@react-types/overlays'; +import {useEffect} from 'react'; import {useOverlay} from './useOverlay'; import {usePreventScroll} from './usePreventScroll'; @@ -113,12 +114,12 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState): isDisabled: isNonModal || !state.isOpen }); - useLayoutEffect(() => { + useEffect(() => { if (state.isOpen && popoverRef.current) { if (isNonModal) { return keepVisible(groupRef?.current ?? popoverRef.current); } else { - return ariaHideOutside([groupRef?.current ?? popoverRef.current]); + return ariaHideOutside([groupRef?.current ?? popoverRef.current], {shouldUseInert: true}); } } }, [isNonModal, state.isOpen, popoverRef, groupRef]); diff --git a/packages/@react-aria/focus/src/isElementVisible.ts b/packages/@react-aria/utils/src/isElementVisible.ts similarity index 90% rename from packages/@react-aria/focus/src/isElementVisible.ts rename to packages/@react-aria/utils/src/isElementVisible.ts index b931d9bf6f5..e404cb7a264 100644 --- a/packages/@react-aria/focus/src/isElementVisible.ts +++ b/packages/@react-aria/utils/src/isElementVisible.ts @@ -10,7 +10,9 @@ * governing permissions and limitations under the License. */ -import {getOwnerWindow} from '@react-aria/utils'; +import {getOwnerWindow} from './domHelpers'; + +const supportsCheckVisibility = typeof Element !== 'undefined' && 'checkVisibility' in Element.prototype; function isStyleVisible(element: Element) { const windowObject = getOwnerWindow(element); @@ -60,6 +62,10 @@ function isAttributeVisible(element: Element, childElement?: Element) { * @param element - Element to evaluate for display or visibility. */ export function isElementVisible(element: Element, childElement?: Element): boolean { + if (supportsCheckVisibility) { + return element.checkVisibility(); + } + return ( element.nodeName !== '#comment' && isStyleVisible(element) && diff --git a/packages/@react-aria/utils/src/isFocusable.ts b/packages/@react-aria/utils/src/isFocusable.ts index f0fd6ad5140..c1a8c0dc6a5 100644 --- a/packages/@react-aria/utils/src/isFocusable.ts +++ b/packages/@react-aria/utils/src/isFocusable.ts @@ -1,3 +1,17 @@ +/* + * Copyright 2025 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 {isElementVisible} from './isElementVisible'; + const focusableElements = [ 'input:not([disabled]):not([type=hidden])', 'select:not([disabled])', @@ -20,9 +34,22 @@ focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])'); const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),'); export function isFocusable(element: Element): boolean { - return element.matches(FOCUSABLE_ELEMENT_SELECTOR); + return element.matches(FOCUSABLE_ELEMENT_SELECTOR) && isElementVisible(element) && !isInert(element); } export function isTabbable(element: Element): boolean { - return element.matches(TABBABLE_ELEMENT_SELECTOR); + return element.matches(TABBABLE_ELEMENT_SELECTOR) && isElementVisible(element) && !isInert(element); +} + +function isInert(element: Element): boolean { + let node: Element | null = element; + while (node != null) { + if (node instanceof node.ownerDocument.defaultView!.HTMLElement && node.inert) { + return true; + } + + node = node.parentElement; + } + + return false; } diff --git a/packages/@react-spectrum/dialog/test/DialogTrigger.test.js b/packages/@react-spectrum/dialog/test/DialogTrigger.test.js index f0eb6ff8efc..52765f37a48 100644 --- a/packages/@react-spectrum/dialog/test/DialogTrigger.test.js +++ b/packages/@react-spectrum/dialog/test/DialogTrigger.test.js @@ -15,7 +15,6 @@ import {ActionButton, Button} from '@react-spectrum/button'; import {ButtonGroup} from '@react-spectrum/buttongroup'; import {Content} from '@react-spectrum/view'; import {Dialog, DialogTrigger} from '../'; -import {Heading} from '@react-spectrum/text'; import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; @@ -977,55 +976,6 @@ describe('DialogTrigger', function () { expect(document.activeElement).toBe(innerInput); }); - it('will not lose focus to body', async () => { - let {getByRole, getByTestId} = render( - - - Trigger - - The Heading - - - Test - - Item 1 - Item 2 - Item 3 - - - - - - - ); - let button = getByRole('button'); - await user.click(button); - - act(() => { - jest.runAllTimers(); - }); - - let outerDialog = getByRole('dialog'); - - await waitFor(() => { - expect(outerDialog).toBeVisible(); - }); // wait for animation - let innerButton = getByTestId('innerButton'); - await user.tab(); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - - act(() => { - jest.runAllTimers(); - }); - await user.tab(); - act(() => { - jest.runAllTimers(); - }); - - expect(document.activeElement).toBe(innerButton); - }); - describe('portalContainer', () => { function InfoDialog(props) { let {container} = props; diff --git a/packages/@react-spectrum/menu/src/MenuTrigger.tsx b/packages/@react-spectrum/menu/src/MenuTrigger.tsx index 8c9e59d8cc7..6f23974af9e 100644 --- a/packages/@react-spectrum/menu/src/MenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/MenuTrigger.tsx @@ -103,7 +103,8 @@ export const MenuTrigger = forwardRef(function MenuTrigger(props: SpectrumMenuTr scrollRef={menuRef} placement={initialPlacement} hideArrow - shouldFlip={shouldFlip}> + shouldFlip={shouldFlip} + shouldContainFocus> {menu} ); diff --git a/packages/@react-spectrum/menu/test/MenuTrigger.test.js b/packages/@react-spectrum/menu/test/MenuTrigger.test.js index 265940e0c88..567ff686c51 100644 --- a/packages/@react-spectrum/menu/test/MenuTrigger.test.js +++ b/packages/@react-spectrum/menu/test/MenuTrigger.test.js @@ -809,7 +809,7 @@ describe('MenuTrigger', function () { }); }); - it('closes if menu is tabbed away from', async function () { + it('does not close if menu is tabbed away from', async function () { let tree = render( @@ -835,8 +835,8 @@ describe('MenuTrigger', function () { await user.tab(); act(() => {jest.runAllTimers();}); act(() => {jest.runAllTimers();}); - expect(menu).not.toBeInTheDocument(); - expect(document.activeElement).toBe(menuTester.trigger); + expect(menu).toBeInTheDocument(); + expect(document.activeElement).toBe(menuTester.options()[0]); }); }); @@ -930,22 +930,6 @@ AriaMenuTests({ multipleSelection: () => render( ), - siblingFocusableElement: () => render( - - - - - - One - Two - Three - - - - - ), multipleMenus: () => render( @@ -1082,24 +1066,6 @@ AriaMenuTests({ multipleSelection: () => render( ), - siblingFocusableElement: () => render( - - - - - - {item => ( -
- {item => {item.name}} -
- )} -
-
- -
- ), multipleMenus: () => render( diff --git a/packages/@react-spectrum/overlays/src/Overlay.tsx b/packages/@react-spectrum/overlays/src/Overlay.tsx index e999fa5a546..7c43194a04e 100644 --- a/packages/@react-spectrum/overlays/src/Overlay.tsx +++ b/packages/@react-spectrum/overlays/src/Overlay.tsx @@ -22,6 +22,7 @@ export const Overlay = React.forwardRef(function Overlay(props: OverlayProps, re children, isOpen, disableFocusManagement, + shouldContainFocus, container, onEnter, onEntering, @@ -56,7 +57,7 @@ export const Overlay = React.forwardRef(function Overlay(props: OverlayProps, re } return ( - + (props: hideArrow state={state} triggerRef={unwrappedTriggerRef} - scrollRef={listboxRef}> + scrollRef={listboxRef} + shouldContainFocus> {listbox} ); diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index da7c4ad6960..3e45c065a67 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -583,9 +583,9 @@ describe('Picker', function () { expect(document.activeElement).toBe(picker); }); - it('tabs to the next element after the trigger and closes the menu', async function () { + it('does not shift focus when tabbing', async function () { let onOpenChange = jest.fn(); - let {getByRole, getByTestId} = render( + let {getByRole} = render( @@ -611,51 +611,10 @@ describe('Picker', function () { fireEvent.keyDown(document.activeElement, {key: 'Tab'}); act(() => jest.runAllTimers()); - expect(listbox).not.toBeInTheDocument(); - expect(picker).toHaveAttribute('aria-expanded', 'false'); - expect(picker).not.toHaveAttribute('aria-controls'); - expect(onOpenChange).toBeCalledTimes(2); - expect(onOpenChange).toHaveBeenCalledWith(false); - - expect(document.activeElement).toBe(getByTestId('after-input')); - }); - - it('shift tabs to the previous element before the trigger and closes the menu', async function () { - let onOpenChange = jest.fn(); - let {getByRole, getByTestId} = render( - - - - One - Two - Three - - - - ); - - let picker = getByRole('button'); - await user.click(picker); - act(() => jest.runAllTimers()); - - let listbox = getByRole('listbox'); - expect(listbox).toBeVisible(); - expect(onOpenChange).toBeCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(listbox).toBeInTheDocument(); expect(picker).toHaveAttribute('aria-expanded', 'true'); expect(picker).toHaveAttribute('aria-controls', listbox.id); - - fireEvent.keyDown(document.activeElement, {key: 'Tab', shiftKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'Tab', shiftKey: true}); - act(() => jest.runAllTimers()); - - expect(listbox).not.toBeInTheDocument(); - expect(picker).toHaveAttribute('aria-expanded', 'false'); - expect(picker).not.toHaveAttribute('aria-controls'); - expect(onOpenChange).toBeCalledTimes(2); - expect(onOpenChange).toHaveBeenCalledWith(false); - - expect(document.activeElement).toBe(getByTestId('before-input')); + expect(document.activeElement).toBe(listbox); }); it('should have a hidden dismiss button for screen readers', async function () { @@ -1930,58 +1889,6 @@ describe('Picker', function () { expect(document.activeElement).toBe(beforeBtn); }); - it('calls onBlur and onFocus for the open Picker', async function () { - let {getByRole, getByTestId} = render( - -