Skip to content

Commit 7639e56

Browse files
devongovettLFDanLu
andauthored
chore: Use inert in ariaHideOutside (#8372)
* Revert "Revert "chore: Use inert in ariaHideOutside (#8317)" (#8371)" This reverts commit 26f9102. * Fix focus ring appearing on S2 submenus * Fix firefox focusing Picker/Menu items when clicking with mouse * Fix dialog re-focusing with iOS VO * Revert "Fix firefox focusing Picker/Menu items when clicking with mouse" This reverts commit 34bc815. --------- Co-authored-by: Daniel Lu <dl1644@gmail.com>
1 parent 5627d88 commit 7639e56

File tree

17 files changed

+92
-226
lines changed

17 files changed

+92
-226
lines changed

packages/@react-aria/dialog/src/useDialog.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ export function useDialog(props: AriaDialogProps, ref: RefObject<FocusableElemen
4747
// or announce that it has opened until it has rendered. A workaround
4848
// is to wait for half a second, then blur and re-focus the dialog.
4949
let timeout = setTimeout(() => {
50-
if (document.activeElement === ref.current) {
50+
// Check that the dialog is still focused, or focused was lost to the body.
51+
if (document.activeElement === ref.current || document.activeElement === document.body) {
5152
isRefocusing.current = true;
5253
if (ref.current) {
5354
ref.current.blur();

packages/@react-aria/dnd/src/DragManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ class DragSession {
408408
this.dragTarget.element,
409409
...validDropItems.flatMap(item => item.activateButtonRef?.current ? [item.element, item.activateButtonRef?.current] : [item.element]),
410410
...visibleDropTargets.flatMap(target => target.activateButtonRef?.current ? [target.element, target.activateButtonRef?.current] : [target.element])
411-
]);
411+
], {shouldUseInert: true});
412412

413413
this.mutationObserver.observe(document.body, {subtree: true, attributes: true, attributeFilter: ['aria-hidden']});
414414
}

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
} from '@react-aria/utils';
2525
import {FocusableElement, RefObject} from '@react-types/shared';
2626
import {focusSafely, getInteractionModality} from '@react-aria/interactions';
27-
import {isElementVisible} from './isElementVisible';
2827
import React, {JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
2928

3029
export interface FocusScopeProps {
@@ -792,7 +791,6 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions
792791
}
793792

794793
if (filter(node as Element)
795-
&& isElementVisible(node as Element)
796794
&& (!scope || isElementInScope(node as Element, scope))
797795
&& (!opts?.accept || opts.accept(node as Element))
798796
) {

packages/@react-aria/overlays/src/ariaHideOutside.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {getOwnerWindow} from '@react-aria/utils';
14+
15+
const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype;
16+
17+
interface AriaHideOutsideOptions {
18+
root?: Element,
19+
shouldUseInert?: boolean
20+
}
21+
1322
// Keeps a ref count of all hidden elements. Added to when hiding an element, and
1423
// subtracted from when showing it again. When it reaches zero, aria-hidden is removed.
1524
let refCountMap = new WeakMap<Element, number>();
@@ -29,10 +38,28 @@ let observerStack: Array<ObserverWrapper> = [];
2938
* @param root - Nothing will be hidden above this element.
3039
* @returns - A function to restore all hidden elements.
3140
*/
32-
export function ariaHideOutside(targets: Element[], root: Element = document.body) {
41+
export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOptions | Element) {
42+
let windowObj = getOwnerWindow(targets?.[0]);
43+
let opts = options instanceof windowObj.Element ? {root: options} : options;
44+
let root = opts?.root ?? document.body;
45+
let shouldUseInert = opts?.shouldUseInert && supportsInert;
3346
let visibleNodes = new Set<Element>(targets);
3447
let hiddenNodes = new Set<Element>();
3548

49+
let getHidden = (element: Element) => {
50+
return shouldUseInert && element instanceof windowObj.HTMLElement ? element.inert : element.getAttribute('aria-hidden') === 'true';
51+
};
52+
53+
let setHidden = (element: Element, hidden: boolean) => {
54+
if (shouldUseInert && element instanceof windowObj.HTMLElement) {
55+
element.inert = hidden;
56+
} else if (hidden) {
57+
element.setAttribute('aria-hidden', 'true');
58+
} else {
59+
element.removeAttribute('aria-hidden');
60+
}
61+
};
62+
3663
let walk = (root: Element) => {
3764
// Keep live announcer and top layer elements (e.g. toasts) visible.
3865
for (let element of root.querySelectorAll('[data-live-announcer], [data-react-aria-top-layer]')) {
@@ -87,12 +114,12 @@ export function ariaHideOutside(targets: Element[], root: Element = document.bod
87114

88115
// If already aria-hidden, and the ref count is zero, then this element
89116
// was already hidden and there's nothing for us to do.
90-
if (node.getAttribute('aria-hidden') === 'true' && refCount === 0) {
117+
if (getHidden(node) && refCount === 0) {
91118
return;
92119
}
93120

94121
if (refCount === 0) {
95-
node.setAttribute('aria-hidden', 'true');
122+
setHidden(node, true);
96123
}
97124

98125
hiddenNodes.add(node);
@@ -161,7 +188,7 @@ export function ariaHideOutside(targets: Element[], root: Element = document.bod
161188
continue;
162189
}
163190
if (count === 1) {
164-
node.removeAttribute('aria-hidden');
191+
setHidden(node, false);
165192
refCountMap.delete(node);
166193
} else {
167194
refCountMap.set(node, count - 1);

packages/@react-aria/overlays/src/useModalOverlay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function useModalOverlay(props: AriaModalOverlayProps, state: OverlayTrig
5858

5959
useEffect(() => {
6060
if (state.isOpen && ref.current) {
61-
return ariaHideOutside([ref.current]);
61+
return ariaHideOutside([ref.current], {shouldUseInert: true});
6262
}
6363
}, [state.isOpen, ref]);
6464

packages/@react-aria/overlays/src/usePopover.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
import {ariaHideOutside, keepVisible} from './ariaHideOutside';
1414
import {AriaPositionProps, useOverlayPosition} from './useOverlayPosition';
1515
import {DOMAttributes, RefObject} from '@react-types/shared';
16-
import {mergeProps, useLayoutEffect} from '@react-aria/utils';
16+
import {mergeProps} from '@react-aria/utils';
1717
import {OverlayTriggerState} from '@react-stately/overlays';
1818
import {PlacementAxis} from '@react-types/overlays';
19+
import {useEffect} from 'react';
1920
import {useOverlay} from './useOverlay';
2021
import {usePreventScroll} from './usePreventScroll';
2122

@@ -113,12 +114,12 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState):
113114
isDisabled: isNonModal || !state.isOpen
114115
});
115116

116-
useLayoutEffect(() => {
117+
useEffect(() => {
117118
if (state.isOpen && popoverRef.current) {
118119
if (isNonModal) {
119120
return keepVisible(groupRef?.current ?? popoverRef.current);
120121
} else {
121-
return ariaHideOutside([groupRef?.current ?? popoverRef.current]);
122+
return ariaHideOutside([groupRef?.current ?? popoverRef.current], {shouldUseInert: true});
122123
}
123124
}
124125
}, [isNonModal, state.isOpen, popoverRef, groupRef]);

packages/@react-aria/focus/src/isElementVisible.ts renamed to packages/@react-aria/utils/src/isElementVisible.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {getOwnerWindow} from '@react-aria/utils';
13+
import {getOwnerWindow} from './domHelpers';
14+
15+
const supportsCheckVisibility = typeof Element !== 'undefined' && 'checkVisibility' in Element.prototype;
1416

1517
function isStyleVisible(element: Element) {
1618
const windowObject = getOwnerWindow(element);
@@ -60,6 +62,10 @@ function isAttributeVisible(element: Element, childElement?: Element) {
6062
* @param element - Element to evaluate for display or visibility.
6163
*/
6264
export function isElementVisible(element: Element, childElement?: Element): boolean {
65+
if (supportsCheckVisibility) {
66+
return element.checkVisibility();
67+
}
68+
6369
return (
6470
element.nodeName !== '#comment' &&
6571
isStyleVisible(element) &&

packages/@react-aria/utils/src/isFocusable.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {isElementVisible} from './isElementVisible';
14+
115
const focusableElements = [
216
'input:not([disabled]):not([type=hidden])',
317
'select:not([disabled])',
@@ -21,9 +35,22 @@ focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])');
2135
const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),');
2236

2337
export function isFocusable(element: Element): boolean {
24-
return element.matches(FOCUSABLE_ELEMENT_SELECTOR);
38+
return element.matches(FOCUSABLE_ELEMENT_SELECTOR) && isElementVisible(element) && !isInert(element);
2539
}
2640

2741
export function isTabbable(element: Element): boolean {
28-
return element.matches(TABBABLE_ELEMENT_SELECTOR);
42+
return element.matches(TABBABLE_ELEMENT_SELECTOR) && isElementVisible(element) && !isInert(element);
43+
}
44+
45+
function isInert(element: Element): boolean {
46+
let node: Element | null = element;
47+
while (node != null) {
48+
if (node instanceof node.ownerDocument.defaultView!.HTMLElement && node.inert) {
49+
return true;
50+
}
51+
52+
node = node.parentElement;
53+
}
54+
55+
return false;
2956
}

packages/@react-spectrum/dialog/test/DialogTrigger.test.js

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {ActionButton, Button} from '@react-spectrum/button';
1515
import {ButtonGroup} from '@react-spectrum/buttongroup';
1616
import {Content} from '@react-spectrum/view';
1717
import {Dialog, DialogTrigger} from '../';
18-
import {Heading} from '@react-spectrum/text';
1918
import {Item, Menu, MenuTrigger} from '@react-spectrum/menu';
2019
import {Provider} from '@react-spectrum/provider';
2120
import React from 'react';
@@ -977,55 +976,6 @@ describe('DialogTrigger', function () {
977976
expect(document.activeElement).toBe(innerInput);
978977
});
979978

980-
it('will not lose focus to body', async () => {
981-
let {getByRole, getByTestId} = render(
982-
<Provider theme={theme}>
983-
<DialogTrigger type="popover">
984-
<ActionButton>Trigger</ActionButton>
985-
<Dialog>
986-
<Heading>The Heading</Heading>
987-
<Content>
988-
<MenuTrigger>
989-
<ActionButton data-testid="innerButton">Test</ActionButton>
990-
<Menu autoFocus="first">
991-
<Item>Item 1</Item>
992-
<Item>Item 2</Item>
993-
<Item>Item 3</Item>
994-
</Menu>
995-
</MenuTrigger>
996-
</Content>
997-
</Dialog>
998-
</DialogTrigger>
999-
</Provider>
1000-
);
1001-
let button = getByRole('button');
1002-
await user.click(button);
1003-
1004-
act(() => {
1005-
jest.runAllTimers();
1006-
});
1007-
1008-
let outerDialog = getByRole('dialog');
1009-
1010-
await waitFor(() => {
1011-
expect(outerDialog).toBeVisible();
1012-
}); // wait for animation
1013-
let innerButton = getByTestId('innerButton');
1014-
await user.tab();
1015-
fireEvent.keyDown(document.activeElement, {key: 'Enter'});
1016-
fireEvent.keyUp(document.activeElement, {key: 'Enter'});
1017-
1018-
act(() => {
1019-
jest.runAllTimers();
1020-
});
1021-
await user.tab();
1022-
act(() => {
1023-
jest.runAllTimers();
1024-
});
1025-
1026-
expect(document.activeElement).toBe(innerButton);
1027-
});
1028-
1029979
describe('portalContainer', () => {
1030980
function InfoDialog(props) {
1031981
let {container} = props;

packages/@react-spectrum/menu/src/MenuTrigger.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ export const MenuTrigger = forwardRef(function MenuTrigger(props: SpectrumMenuTr
103103
scrollRef={menuRef}
104104
placement={initialPlacement}
105105
hideArrow
106-
shouldFlip={shouldFlip}>
106+
shouldFlip={shouldFlip}
107+
shouldContainFocus>
107108
{menu}
108109
</Popover>
109110
);

0 commit comments

Comments
 (0)