diff --git a/packages/react-aria/src/interactions/useFocusVisible.ts b/packages/react-aria/src/interactions/useFocusVisible.ts
index 9e992f5334f..e1d0ac089dd 100644
--- a/packages/react-aria/src/interactions/useFocusVisible.ts
+++ b/packages/react-aria/src/interactions/useFocusVisible.ts
@@ -110,17 +110,29 @@ function handleClickEvent(e: MouseEvent) {
}
function handleFocusEvent(e: FocusEvent) {
+ if (ignoreFocusEvent) {
+ return;
+ }
+
+ let target = getEventTarget(e);
+ let ownerWindow = getOwnerWindow(target);
+ let ownerDocument = getOwnerDocument(target);
+
+ // When the window regains focus, the browser restores focus to the element that was focused
+ // before, firing a focus event the user did not initiate. handleWindowBlur sets
+ // hasBlurredWindowRecently so restored focus doesn't switch to virtual modality below, but
+ // Safari fires the window/element focus pair twice when returning to a tab or app and the first
+ // element focus event clears the flag, so re-arm it whenever the window itself is focused.
+ // Like handleWindowBlur, this intentionally doesn't check isTrusted.
+ if (target === ownerWindow) {
+ hasBlurredWindowRecently = true;
+ return;
+ }
+
// Firefox fires two extra focus events when the user first clicks into an iframe:
// first on the window, then on the document. We ignore these events so they don't
// cause keyboard focus rings to appear.
- let ownerWindow = getOwnerWindow(getEventTarget(e));
- let ownerDocument = getOwnerDocument(getEventTarget(e));
- if (
- getEventTarget(e) === ownerWindow ||
- getEventTarget(e) === ownerDocument ||
- ignoreFocusEvent ||
- !e.isTrusted
- ) {
+ if (target === ownerDocument || !e.isTrusted) {
return;
}
diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js
index d469996ee35..96855cc80c0 100644
--- a/packages/react-aria/test/interactions/useFocusVisible.test.js
+++ b/packages/react-aria/test/interactions/useFocusVisible.test.js
@@ -75,6 +75,45 @@ function toggleBrowserTabs(win = window) {
fireEvent(lastActiveElement, new Event('focus'));
}
+// Focus the element the way the browser restores focus. useFocusVisible ignores untrusted focus
+// events and jsdom only marks its own as trusted, so go through jsdom's focus() rather than
+// fireEvent, using the unpatched copy saved by setupGlobalFocusEvents so it isn't recorded as a
+// programmatic focus. jsdom won't dispatch focus again for the element that already has it, hence
+// the blur first — useFocusVisible doesn't observe element blur, since its blur listener is on the
+// window without capture and blur doesn't bubble.
+function restoreFocus(element, win = window) {
+ const nativeFocus = hasSetupGlobalListeners.get(win).focus;
+ element.blur();
+ nativeFocus.call(element);
+}
+
+function toggleBrowserTabsSafari(win = window) {
+ // Safari fires the window/element focus pair twice when returning to a tab or app, and
+ // visibilitychange fires after all of the focus events.
+ // See https://github.com/adobe/react-spectrum/issues/7468
+ const lastActiveElement = win.document.activeElement;
+ // leave tab
+ fireEvent(lastActiveElement, new Event('blur'));
+ fireEvent(win, new Event('blur'));
+ Object.defineProperty(win.document, 'visibilityState', {
+ value: 'hidden',
+ writable: true
+ });
+ Object.defineProperty(win.document, 'hidden', {value: true, writable: true});
+ fireEvent(win.document, new Event('visibilitychange'));
+ // return to tab
+ fireEvent(win, new Event('focus', {target: win}));
+ restoreFocus(lastActiveElement, win);
+ fireEvent(win, new Event('focus', {target: win}));
+ restoreFocus(lastActiveElement, win);
+ Object.defineProperty(win.document, 'visibilityState', {
+ value: 'visible',
+ writable: true
+ });
+ Object.defineProperty(win.document, 'hidden', {value: false, writable: true});
+ fireEvent(win.document, new Event('visibilitychange'));
+}
+
function toggleBrowserWindow(win = window) {
fireEvent(win, new Event('blur', {target: win}));
fireEvent(win, new Event('focus', {target: win}));
@@ -111,6 +150,27 @@ describe('useFocusVisible', function () {
expect(el.textContent).toBe('example');
});
+ it('returns positive isFocusVisible result after toggling browser tabs in Safari after keyboard navigation', async function () {
+ render();
+ await user.tab();
+ let el = screen.getByText('example-focusVisible');
+
+ toggleBrowserTabsSafari();
+
+ expect(el.textContent).toBe('example-focusVisible');
+ });
+
+ it('returns negative isFocusVisible result after toggling browser tabs in Safari without prior keyboard navigation', async function () {
+ render();
+ await user.tab();
+ let el = screen.getByText('example-focusVisible');
+
+ await user.click(el);
+ toggleBrowserTabsSafari();
+
+ expect(el.textContent).toBe('example');
+ });
+
it('returns positive isFocusVisible result after toggling browser window after keyboard navigation', async function () {
render();
await user.tab();